From b9296b2ef836df227d12c4ebf8b2f29af7b74720 Mon Sep 17 00:00:00 2001 From: Marius Vollmer Date: Mon, 7 Oct 2024 16:42:31 +0300 Subject: [PATCH] shell: Use a top-level React component And rewrite much of the state handling to make it easier to understand. Specifically, the old Index and MachineIndex classes and their complicated interactions have been replaced with a hopefully much more straightforward ShellState class. However, the existing React components such as CockpitHosts and TopNav have not been significantly touched. The API for launching the HostModal dialogs has been changed to make it more suitable for a later rewrite with Dialogs.run() ala pkg/lib/cockpit-connect-ssh. --- files.js | 2 +- pkg/shell/active-pages-modal.jsx | 29 +- pkg/shell/base_index.js | 927 ------------------------- pkg/shell/failures.jsx | 145 +++- pkg/shell/frames.jsx | 215 ++++++ pkg/shell/hosts.jsx | 175 +++-- pkg/shell/hosts_dialog.jsx | 230 ++++-- pkg/shell/idle.jsx | 125 ++++ pkg/shell/index.html | 42 +- pkg/shell/indexes.jsx | 642 ----------------- pkg/shell/machines/machines.js | 57 +- pkg/shell/nav.jsx | 126 +++- pkg/shell/router.jsx | 293 ++++++++ pkg/shell/shell-modals.jsx | 13 - pkg/shell/shell.js | 34 - pkg/shell/shell.jsx | 198 ++++++ pkg/shell/shell.scss | 5 +- pkg/shell/state.jsx | 584 ++++++++++++++++ pkg/shell/topnav.jsx | 72 +- pkg/shell/util.jsx | 268 +++++++ test/verify/check-shell-host-switching | 27 +- test/verify/check-shell-multi-machine | 6 +- 22 files changed, 2254 insertions(+), 1961 deletions(-) delete mode 100644 pkg/shell/base_index.js create mode 100644 pkg/shell/frames.jsx create mode 100644 pkg/shell/idle.jsx delete mode 100644 pkg/shell/indexes.jsx create mode 100644 pkg/shell/router.jsx delete mode 100644 pkg/shell/shell.js create mode 100644 pkg/shell/shell.jsx create mode 100644 pkg/shell/state.jsx create mode 100644 pkg/shell/util.jsx diff --git a/files.js b/files.js index d839c5252c9e..cba395c40058 100644 --- a/files.js +++ b/files.js @@ -28,7 +28,7 @@ const info = { "playground/remote.tsx", "selinux/selinux.js", - "shell/shell.js", + "shell/shell.jsx", "sosreport/sosreport.jsx", "static/login.js", "storaged/storaged.jsx", diff --git a/pkg/shell/active-pages-modal.jsx b/pkg/shell/active-pages-modal.jsx index ffbc21d3a3fe..6ae951ff5ab8 100644 --- a/pkg/shell/active-pages-modal.jsx +++ b/pkg/shell/active-pages-modal.jsx @@ -29,20 +29,18 @@ import { useInit } from "hooks"; const _ = cockpit.gettext; -export const ActivePagesDialog = ({ dialogResult, frames }) => { +export const ActivePagesDialog = ({ dialogResult, state }) => { function get_pages() { const result = []; - for (const address in frames.iframes) { - for (const component in frames.iframes[address]) { - const iframe = frames.iframes[address][component]; + for (const frame of state.frames) { + if (frame.url) { + const active = (frame == state.current_frame || + state.most_recent_path_for_host(frame.host) == frame.path); result.push({ - frame: iframe, - component, - address, - name: iframe.getAttribute("name"), - active: iframe.getAttribute("data-active") === 'true', - selected: iframe.getAttribute("data-active") === 'true', - displayName: address === "localhost" ? "/" + component : address + ":/" + component + frame, + active, + selected: active, + displayName: frame.host === "localhost" ? "/" + frame.path : frame.host + ":/" + frame.path, }); } } @@ -61,8 +59,9 @@ export const ActivePagesDialog = ({ dialogResult, frames }) => { function onRemove() { pages.forEach(element => { - if (element.selected) - frames.remove(element.host, element.component); + if (element.selected) { + state.remove_frame(element.frame.name); + } }); dialogResult.resolve(); } @@ -80,8 +79,8 @@ export const ActivePagesDialog = ({ dialogResult, frames }) => { }]; return ({ props: { - key: page.name, - 'data-row-id': page.name + key: page.frame.name, + 'data-row-id': page.frame.name }, columns, selected: page.selected, diff --git a/pkg/shell/base_index.js b/pkg/shell/base_index.js deleted file mode 100644 index 3271f9ce5904..000000000000 --- a/pkg/shell/base_index.js +++ /dev/null @@ -1,927 +0,0 @@ -/* - * This file is part of Cockpit. - * - * Copyright (C) 2015 Red Hat, Inc. - * - * Cockpit is free software; you can redistribute it and/or modify it - * under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation; either version 2.1 of the License, or - * (at your option) any later version. - * - * Cockpit is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Cockpit; If not, see . - */ - -import cockpit from "cockpit"; -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { TimeoutModal } from "./shell-modals.jsx"; - -const shell_embedded = window.location.pathname.indexOf(".html") !== -1; -const _ = cockpit.gettext; - -function component_checksum(machine, component) { - const parts = component.split("/"); - const pkg = parts[0]; - if (machine.manifests && machine.manifests[pkg] && machine.manifests[pkg][".checksum"]) - return "$" + machine.manifests[pkg][".checksum"]; -} - -function Frames(index, setupIdleResetTimers) { - const self = this; - let language = document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1"); - if (!language) - language = navigator.language.toLowerCase(); // Default to Accept-Language header - - /* Lists of frames, by host */ - self.iframes = { }; - - function remove_frame(frame) { - frame.remove(); - } - - self.remove = function remove(machine, component) { - let address; - if (typeof machine == "string") - address = machine; - else if (machine) - address = machine.address; - if (!address) - address = "localhost"; - const list = self.iframes[address] || { }; - if (!component) - delete self.iframes[address]; - Object.keys(list).forEach(function(key) { - if (!component || component == key) { - remove_frame(list[key]); - delete list[component]; - } - }); - }; - - function frame_ready(frame, count) { - let ready = false; - - window.clearTimeout(frame.timer); - frame.timer = null; - - try { - if (frame.contentWindow.document && frame.contentWindow.document.body) - ready = frame.contentWindow.document.body.offsetWidth > 0 && frame.contentWindow.document.body.offsetHeight > 0; - } catch (ex) { - ready = true; - } - - if (!count) - count = 0; - count += 1; - if (count > 50) - ready = true; - - if (ready) { - if (frame.getAttribute("data-ready") != "1") { - frame.setAttribute("data-ready", "1"); - if (count > 0) - index.navigate(); - } - if (frame.contentWindow && setupIdleResetTimers) - setupIdleResetTimers(frame.contentWindow); - - if (frame.contentDocument && frame.contentDocument.documentElement) { - frame.contentDocument.documentElement.lang = language; - if (cockpit.language_direction) - frame.contentDocument.documentElement.dir = cockpit.language_direction; - } - } else { - frame.timer = window.setTimeout(function() { - frame_ready(frame, count + 1); - }, 100); - } - } - - self.lookup = function lookup(machine, component, hash) { - let host; - let address; - let new_frame = false; - - if (typeof machine == "string") { - address = host = machine; - } else if (machine) { - host = machine.connection_string; - address = machine.address; - } - - if (!host) - host = "localhost"; - if (!address) - address = host; - - let list = self.iframes[address]; - if (!list) - self.iframes[address] = list = { }; - - const name = "cockpit1:" + host + "/" + component; - let frame = list[component]; - if (frame && frame.getAttribute("name") != name) { - remove_frame(frame); - frame = null; - } - - /* A preloaded frame */ - if (!frame) { - const wind = window.frames[name]; - if (wind) - frame = wind.frameElement; - if (frame) { - const src = frame.getAttribute('src'); - frame.url = src.split("#")[0]; - list[component] = frame; - } - } - - /* Need to create a new frame */ - if (!frame) { - new_frame = true; - frame = document.createElement("iframe"); - frame.setAttribute("class", "container-frame"); - frame.setAttribute("name", name); - frame.setAttribute("data-host", host); - frame.style.display = "none"; - - let base, checksum; - if (machine) { - if (machine.manifests && machine.manifests[".checksum"]) - checksum = "$" + machine.manifests[".checksum"]; - else - checksum = machine.checksum; - } - - if (checksum && checksum == component_checksum(machine, component)) { - if (host === "localhost") - base = ".."; - else - base = "../../" + checksum; - } else { - /* If we don't have any checksums, or if the component specifies a different - checksum than the machine, load it via a non-caching @ path. This - makes sure that we get the right files, and also that we don't poisen the - cache with wrong files. - - We can't use a $ path since cockpit-ws only knows how to - route the machine checksum. - - TODO - make it possible to use $. - */ - base = "../../@" + host; - } - - frame.url = base + "/" + component; - if (component.indexOf("/") === -1) - frame.url += "/index"; - frame.url += ".html"; - } - - if (!hash) - hash = "/"; - const src = frame.url + "#" + hash; - if (frame.getAttribute('src') != src) { - if (frame.contentWindow) { - // This prevents the browser from creating a new - // history entry. It would do that whenever the "src" - // of a frame is changed and the window location is - // not consistent with the new "src" value. - // - // This matters when a "jump" command changes both the - // the current frame and the hash of the new frame. - frame.contentWindow.location.replace(src); - } - frame.setAttribute('src', src); - } - - /* Store frame only when fully setup */ - if (new_frame) { - list[component] = frame; - document.getElementById("content").appendChild(frame); - - const style = localStorage.getItem('shell:style') || 'auto'; - let dark_mode; - // If a user set's an explicit theme, ignore system changes. - if ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && style === "auto") || style === "dark") { - dark_mode = true; - } else { - dark_mode = false; - } - - // The new iframe is shown before any HTML/CSS is ready and loaded, - // explicitly set a dark background so we don't see any white flashes - if (dark_mode && frame.contentDocument && frame.contentDocument.documentElement) { - // --pf-v5-global--BackgroundColor--dark-300 - const dark_mode_background = '#1b1d21'; - frame.contentDocument.documentElement.style.background = dark_mode_background; - } else { - frame.contentDocument.documentElement.style.background = 'white'; - } - } - frame_ready(frame); - return frame; - }; -} - -function Router(index) { - const self = this; - - let unique_id = 0; - const origin = cockpit.transport.origin; - const source_by_seed = { }; - const source_by_name = { }; - - cockpit.transport.filter(function(message, channel, control) { - /* Only control messages with a channel are forwardable */ - if (control) { - if (control.channel !== undefined) { - for (const seed in source_by_seed) { - const source = source_by_seed[seed]; - if (!source.window.closed) - source.window.postMessage(message, origin); - } - } else if (control.command == "hint") { - /* This is where we handle hint messages directed at - * the shell. Right now, there aren't any. - */ - } - - /* Forward message to relevant frame */ - } else if (channel) { - const pos = channel.indexOf('!'); - if (pos !== -1) { - const seed = channel.substring(0, pos + 1); - const source = source_by_seed[seed]; - if (source) { - if (!source.window.closed) - source.window.postMessage(message, origin); - return false; /* Stop delivery */ - } - } - } - - /* Still deliver the message locally */ - return true; - }, false); - - function perform_jump(child, control) { - const current_frame = index.current_frame(); - if (child !== window) { - if (!current_frame || current_frame.contentWindow != child) - return; - } - let str = control.location || ""; - if (str[0] != "/") - str = "/" + str; - if (control.host) - str = "/@" + encodeURIComponent(control.host) + str; - index.jump(str); - } - - function perform_track(child) { - const current_frame = index.current_frame(); - /* Note that we ignore tracknig for old shell code */ - if (current_frame && current_frame.contentWindow === child && - child.name && child.name.indexOf("/shell/shell") === -1) { - let hash = child.location.hash; - if (hash.indexOf("#") === 0) - hash = hash.substring(1); - if (hash === "/") - hash = ""; - /* The browser has already pushed an appropriate entry to - the history, so let's just replace it with our custom - state object. - */ - const state = Object.assign({}, index.retrieve_state(), { hash }); - index.navigate(state, true); - } - } - - function on_unload(ev) { - let source; - if (ev.target.defaultView) - source = source_by_name[ev.target.defaultView.name]; - else if (ev.view) - source = source_by_name[ev.view.name]; - if (source) - unregister(source); - } - - function on_hashchange(ev) { - const source = source_by_name[ev.target.name]; - if (source) - perform_track(source.window); - } - - function on_load(ev) { - const source = source_by_name[ev.target.contentWindow.name]; - if (source) - perform_track(source.window); - } - - function unregister(source) { - const child = source.window; - cockpit.kill(null, child.name); - const frame = child.frameElement; - if (frame) - frame.removeEventListener("load", on_load); - /* This is often invalid when the window is closed */ - if (child.removeEventListener) { - child.removeEventListener("unload", on_unload); - child.removeEventListener("hashchange", on_hashchange); - } - delete source_by_seed[source.channel_seed]; - delete source_by_name[source.name]; - } - - function register(child) { - let host, page; - const name = child.name || ""; - if (name.indexOf("cockpit1:") === 0) { - const parts = name.substring(9).split("/"); - host = parts[0]; - page = parts.slice(1).join("/"); - } - if (!name || !host || !page) { - console.warn("invalid child window name", child, name); - return; - } - - unique_id += 1; - const seed = (cockpit.transport.options["channel-seed"] || "undefined:") + unique_id + "!"; - const source = { - name, - window: child, - channel_seed: seed, - default_host: host, - page, - inited: false, - }; - source_by_seed[seed] = source; - source_by_name[name] = source; - - const frame = child.frameElement; - frame.addEventListener("load", on_load); - child.addEventListener("unload", on_unload); - child.addEventListener("hashchange", on_hashchange); - - /* - * Setting the "data-loaded" attribute helps the testsuite - * know when it can switch into the frame and inject its - * own additions. - */ - frame.setAttribute('data-loaded', '1'); - - perform_track(child); - - index.navigate(); - return source; - } - - function message_handler(event) { - if (event.origin !== origin) - return; - - let data = event.data; - const child = event.source; - if (!child) - return; - - /* If it's binary data just send it. - * TODO: Once we start restricting what frames can - * talk to which hosts, we need to parse control - * messages here, and cross check channels */ - if (data instanceof window.ArrayBuffer) { - cockpit.transport.inject(data, true); - return; - } - - if (typeof data !== "string") - return; - - let source, control; - - /* - * On Internet Explorer we see Access Denied when non Cockpit - * frames send messages (such as Javascript console). This also - * happens when the window is closed. - */ - try { - source = source_by_name[child.name]; - } catch (ex) { - console.log("received message from child with in accessible name: ", ex); - return; - } - - /* Closing the transport */ - if (data.length === 0) { - if (source) - unregister(source); - return; - } - - /* A control message */ - if (data[0] == '\n') { - control = JSON.parse(data.substring(1)); - if (control.command === "init") { - if (source) - unregister(source); - if (control.problem) { - console.warn("child frame failed to init: " + control.problem); - source = null; - } else { - source = register(child); - } - if (source) { - const reply = { - ...cockpit.transport.options, - command: "init", - host: source.default_host, - "channel-seed": source.channel_seed, - }; - child.postMessage("\n" + JSON.stringify(reply), origin); - source.inited = true; - - /* If this new frame is not the current one, tell it */ - if (child.frameElement != index.current_frame()) - self.hint(child.frameElement.contentWindow, { hidden: true }); - } - } else if (control.command === "jump") { - perform_jump(child, control); - return; - } else if (control.command === "hint") { - if (control.hint == "restart") { - /* watchdog handles current host for now */ - if (control.host != cockpit.transport.host) - index.expect_restart(control.host); - } else - cockpit.hint(control.hint, control); - return; - } else if (control.command == "oops") { - index.show_oops(); - return; - } else if (control.command == "notify") { - if (source) - index.handle_notifications(source.default_host, source.page, control); - return; - - /* Only control messages with a channel are forwardable */ - } else if (control.channel === undefined && (control.command !== "logout" && control.command !== "kill")) { - return; - - /* Add the child's group to all open channel messages */ - } else if (control.command == "open") { - control.group = child.name; - data = "\n" + JSON.stringify(control); - } - } - - if (!source) { - console.warn("child frame " + child.name + " sending data without init"); - return; - } - - /* Everything else gets forwarded */ - cockpit.transport.inject(data, true); - } - - self.start = function start(messages) { - window.addEventListener("message", message_handler, false); - for (let i = 0, len = messages.length; i < len; i++) - message_handler(messages[i]); - }; - - self.hint = function hint(child, data) { - const source = source_by_name[child.name]; - /* This is often invalid when the window is closed */ - if (source && source.inited && !source.window.closed) { - data.command = "hint"; - const message = "\n" + JSON.stringify(data); - source.window.postMessage(message, origin); - } - }; -} - -/* - * New instances of Index must be created by new_index_from_proto - * and the caller must include a navigation function in the given - * prototype. That function will be called by Frames and - * Router to actually perform any navigation action. - * - * Emits "disconnect" and "expect_restart" signals, that should be - * handled by the caller. - */ -function Index() { - const self = this; - let current_frame; - - cockpit.event_target(self); - - if (typeof self.navigate !== "function") - throw Error("Index requires a prototype with a navigate function"); - - /* Session timing out after inactivity */ - let session_final_timer = null; - let session_timeout = 0; - let current_idle_time = 0; - let final_countdown = 30000; // last 30 seconds - let title = ""; - const standard_login = window.localStorage['standard-login']; - - self.has_oops = false; - - function sessionTimeout() { - current_idle_time += 5000; - if (!session_final_timer && current_idle_time >= session_timeout - final_countdown) { - title = document.title; - sessionFinalTimeout(); - } - } - - let session_timeout_dialog_root = null; - - function updateFinalCountdown() { - const remaining_secs = Math.floor(final_countdown / 1000); - const timeout_text = cockpit.format(_("You will be logged out in $0 seconds."), remaining_secs); - document.title = "(" + remaining_secs + ") " + title; - if (!session_timeout_dialog_root) - session_timeout_dialog_root = createRoot(document.getElementById('session-timeout-dialog')); - session_timeout_dialog_root.render(React.createElement(TimeoutModal, { - onClose: () => { - window.clearTimeout(session_final_timer); - session_final_timer = null; - document.title = title; - resetTimer(); - session_timeout_dialog_root.unmount(); - session_timeout_dialog_root = null; - final_countdown = 30000; - }, - text: timeout_text, - })); - } - - function sessionFinalTimeout() { - final_countdown -= 1000; - if (final_countdown > 0) { - updateFinalCountdown(); - session_final_timer = window.setTimeout(sessionFinalTimeout, 1000); - } else { - cockpit.logout(true, _("You have been logged out due to inactivity.")); - } - } - - /* Auto-logout idle timer */ - function resetTimer(ev) { - if (!session_final_timer) { - current_idle_time = 0; - } - } - - function setupIdleResetTimers(win) { - win.addEventListener("mousemove", resetTimer, false); - win.addEventListener("mousedown", resetTimer, false); - win.addEventListener("keypress", resetTimer, false); - win.addEventListener("touchmove", resetTimer, false); - win.addEventListener("scroll", resetTimer, false); - } - - cockpit.dbus(null, { bus: "internal" }).call("/config", "cockpit.Config", "GetUInt", ["Session", "IdleTimeout", 0, 240, 0], []) - .then(result => { - session_timeout = result[0] * 60000; - if (session_timeout > 0 && standard_login) { - setupIdleResetTimers(window); - window.setInterval(sessionTimeout, 5000); - } - }) - .catch(e => { - if (e.message.indexOf("GetUInt not available") === -1) - console.warn(e.message); - }); - - self.frames = new Frames(self, setupIdleResetTimers); - self.router = new Router(self); - - /* Watchdog for disconnect */ - const watchdog = cockpit.channel({ payload: "null" }); - watchdog.addEventListener("close", (event, options) => { - const watchdog_problem = options.problem || "disconnected"; - console.warn("transport closed: " + watchdog_problem); - self.dispatchEvent("disconnect", watchdog_problem); - }); - - const old_onerror = window.onerror; - window.onerror = function cockpit_error_handler(msg, url, line) { - // Errors with url == "" are not logged apparently, so let's - // not show the "Oops" for them either. - if (url != "") - self.show_oops(); - if (old_onerror) - return old_onerror(msg, url, line); - return false; - }; - - /* - * Navigation is driven by state objects, which are used with pushState() - * and friends. The state is the canonical navigation location, and not - * the URL. Only when no state has been pushed or we are arriving from - * a link, do we parse the state from the URL. - * - * Each state object has: - * host: a machine host - * component: the stripped component to load - * hash: the hash to pass to the component - * sidebar: set to true to hint that we want a component with a sidebar - * - * If state.sidebar is set, and no component has yet been chosen for the - * given state, then we try to find one that would show a sidebar. - */ - - /* Encode navigate state into a string - * If with_root is true the configured - * url root will be added to the generated - * url. with_root should be used when - * navigating to a new url or updating - * history, but is not needed when simply - * generating a string for a link. - */ - function encode(state, sidebar, with_root) { - const path = []; - if (state.host && (sidebar || state.host !== "localhost")) - path.push("@" + state.host); - if (state.component) - path.push.apply(path, state.component.split("/")); - let string = cockpit.location.encode(path, null, with_root); - if (state.hash && state.hash !== "/") - string += "#" + state.hash; - return string; - } - - /* Decodes navigate state from a string */ - function decode(string) { - const state = { version: "v1", hash: "" }; - const pos = string.indexOf("#"); - if (pos !== -1) { - state.hash = string.substring(pos + 1); - string = string.substring(0, pos); - } - if (string[0] != '/') - string = "/" + string; - const path = cockpit.location.decode(string); - if (path[0] && path[0][0] == "@") { - state.host = path.shift().substring(1); - state.sidebar = true; - } else { - state.host = "localhost"; - } - if (path.length && path[path.length - 1] == "index") - path.pop(); - state.component = path.join("/"); - return state; - } - - self.retrieve_state = function() { - let state = window.history.state; - if (!state || state.version !== "v1") { - if (shell_embedded) - state = decode("/" + window.location.hash); - else - state = decode(window.location.pathname + window.location.hash); - } - return state; - }; - - function lookup_component_hash(address, component) { - if (!address) - address = "localhost"; - - const list = self.frames.iframes[address]; - const iframe = list ? list[component] : undefined; - - if (iframe) { - const src = iframe.getAttribute('src'); - if (src) - return src.split("#")[1]; - } - - return null; - } - - self.preload_frames = function (host, manifests) { - for (const c in manifests) { - const preload = manifests[c].preload; - if (preload && preload.length) { - for (const p of preload) { - if (p == "index") - self.frames.lookup(host, c); - else - self.frames.lookup(host, c + "/" + p); - } - } - } - }; - - /* Jumps to a given navigate state */ - self.jump = function (state, replace) { - if (typeof (state) === "string") - state = decode(state); - - const current = self.retrieve_state(); - - /* Make sure we have the data we need */ - if (!state.host) - state.host = current.host || "localhost"; - - // When switching hosts, check if we left from some page - if (!state.component && state.host !== current.host) { - const host_frames = self.frames.iframes[state.host] || {}; - const active = Object.keys(host_frames) - .filter(k => host_frames[k].getAttribute('data-active') === 'true'); - if (active.length > 0) - state.component = active[0]; - } - - if (!("component" in state)) - state.component = current.component || ""; - - const history = window.history; - const frame_change = (state.host !== current.host || - state.component !== current.component); - - if (frame_change && !state.hash) - state.hash = lookup_component_hash(state.host, state.component); - - const target = shell_embedded ? window.location : encode(state, null, true); - - if (replace) { - history.replaceState(state, "", target); - return false; - } - - if (frame_change || state.hash !== current.hash) { - history.pushState(state, "", target); - document.getElementById("nav-system").classList.remove("interact"); - self.navigate(state, true); - return true; - } - - return false; - }; - - /* Build an href for use in an */ - self.href = function (state, sidebar) { - return encode(state, sidebar); - }; - - self.show_oops = function () { - self.has_oops = true; - self.dispatchEvent("update"); - }; - - self.current_frame = function (frame) { - if (frame !== undefined) { - if (current_frame !== frame) { - if (current_frame && current_frame.contentWindow) - self.router.hint(current_frame.contentWindow, { hidden: true }); - if (frame && frame.contentWindow) - self.router.hint(frame.contentWindow, { hidden: false }); - } - current_frame = frame; - } - return current_frame; - }; - - self.start = function() { - /* window.messages is initialized in shell/indexes.jsx */ - const messages = window.messages; - if (messages) - messages.cancel(); - self.router.start(messages || []); - }; - - self.ready = function () { - window.addEventListener("popstate", ev => { - self.navigate(ev.state, true); - }); - - self.navigate(null, true); - cockpit.translate(); - document.body.removeAttribute("hidden"); - }; - - self.expect_restart = function (host) { - self.dispatchEvent("expect_restart", host); - }; -} - -function CompiledComponents() { - const self = this; - self.items = {}; - - self.load = function(manifests, section) { - Object.entries(manifests || { }).forEach(([name, manifest]) => { - Object.entries(manifest[section] || { }).forEach(([prop, info]) => { - const item = { - section, - label: cockpit.gettext(info.label) || prop, - order: info.order === undefined ? 1000 : info.order, - docs: info.docs, - keywords: info.keywords || [{ matches: [] }], - keyword: { score: -1 } - }; - - // Always first keyword should be page name - const page_name = item.label.toLowerCase(); - if (item.keywords[0].matches.indexOf(page_name) < 0) - item.keywords[0].matches.unshift(page_name); - - // Keywords from manifest have different defaults than are usual - item.keywords.forEach(i => { - i.weight = i.weight || 3; - i.translate = i.translate === undefined ? true : i.translate; - }); - - if (info.path) - item.path = info.path.replace(/\.html$/, ""); - else - item.path = name + "/" + prop; - - /* Split out any hash in the path */ - const pos = item.path.indexOf("#"); - if (pos !== -1) { - item.hash = item.path.substr(pos + 1); - item.path = item.path.substr(0, pos); - } - - /* Fix component for compatibility and normalize it */ - if (item.path.indexOf("/") === -1) - item.path = name + "/" + item.path; - if (item.path.slice(-6) == "/index") - item.path = item.path.slice(0, -6); - self.items[item.path] = item; - }); - }); - }; - - self.ordered = function(section) { - const list = []; - for (const x in self.items) { - if (!section || self.items[x].section === section) - list.push(self.items[x]); - } - list.sort(function(a, b) { - let ret = a.order - b.order; - if (ret === 0) - ret = a.label.localeCompare(b.label); - return ret; - }); - return list; - }; - - self.search = function(prop, value) { - for (const x in self.items) { - if (self.items[x][prop] === value) - return self.items[x]; - } - }; -} - -function follow(arg) { - /* A promise of some sort */ - if (arguments.length == 1 && typeof arg.then == "function") { - arg.then(function() { console.log.apply(console, arguments) }, - function() { console.error.apply(console, arguments) }); - if (typeof arg.stream == "function") - arg.stream(function() { console.log.apply(console, arguments) }); - } -} - -let zz_value; - -/* For debugging utility in the index window */ -Object.defineProperties(window, { - cockpit: { value: cockpit }, - zz: { - get: function() { return zz_value }, - set: function(val) { zz_value = val; follow(val) } - } -}); - -export function new_index_from_proto(proto) { - const o = new Object(proto); // eslint-disable-line no-new-object - Index.call(o); - return o; -} - -export function new_compiled() { - return new CompiledComponents(); -} diff --git a/pkg/shell/failures.jsx b/pkg/shell/failures.jsx index 92a2a64254d0..d45f93c72dfd 100644 --- a/pkg/shell/failures.jsx +++ b/pkg/shell/failures.jsx @@ -28,52 +28,123 @@ import { ExclamationCircleIcon } from "@patternfly/react-icons"; import { EmptyStatePanel } from "cockpit-components-empty-state.jsx"; +import { codes } from "./hosts_dialog.jsx"; + const _ = cockpit.gettext; -export const EarlyFailure = ({ ca_cert_url }) => { +export const EarlyFailure = () => { + let ca_cert_url = null; + if (window.navigator.userAgent.indexOf("Safari") >= 0) + ca_cert_url = window.sessionStorage.getItem("CACertUrl"); + return ( - - - -
{_("There was an unexpected error while connecting to the machine.")}
-
{_("Messages related to the failure might be found in the journal:")}
- journalctl -u cockpit - {ca_cert_url &&
-
{_("Safari users need to import and trust the certificate of the self-signing CA:")}
- -
} - - } /> -
-
+
+ + + +
{_("There was an unexpected error while connecting to the machine.")}
+
{_("Messages related to the failure might be found in the journal:")}
+ journalctl -u cockpit + {ca_cert_url &&
+
{_("Safari users need to import and trust the certificate of the self-signing CA:")}
+ +
} + + } /> +
+
+
); }; -export const EarlyFailureReady = ({ loading, title, paragraph, reconnect, troubleshoot, onTroubleshoot, watchdog_problem, navigate }) => { - const onReconnect = () => { - if (watchdog_problem) { - cockpit.sessionStorage.clear(); - window.location.reload(true); +const EarlyFailureReady = ({ + loading, + title, + paragraph, + reconnect, + troubleshoot, + onTroubleshoot, + watchdog_problem, + onReconnect +}) => { + return ( +
+ + + + {reconnect && + } + {troubleshoot && + } + } + paragraph={paragraph} /> + + +
+ ); +}; + +export const Disconnected = ({ problem }) => { + return ( + { + cockpit.sessionStorage.clear(); + window.location.reload(true); + }} + paragraph={cockpit.message(problem)} /> + ); +}; + +export const MachineTroubleshoot = ({ machine, onClick }) => { + const connecting = (machine.state == "connecting"); + let title, message; + if (machine.restarting) { + title = _("The machine is rebooting"); + message = ""; + } else if (connecting) { + title = _("Connecting to the machine"); + message = ""; + } else { + title = _("Not connected to host"); + if (machine.problem == "not-found") { + message = _("Cannot connect to an unknown host"); } else { - navigate(null, true); + const error = machine.problem || machine.state; + if (error) + message = cockpit.message(error); + else + message = ""; } - }; + } + + let troubleshooting = false; + + if (!machine.restarting && (machine.problem === "no-host" || !!codes[machine.problem])) { + troubleshooting = true; + } + + const restarting = !!machine.restarting; + const reconnect = !connecting && machine.problem != "not-found" && !troubleshooting; return ( - - - - {reconnect && } - {troubleshoot && } - } - paragraph={paragraph} /> - - + ); }; diff --git a/pkg/shell/frames.jsx b/pkg/shell/frames.jsx new file mode 100644 index 000000000000..303d5d30d505 --- /dev/null +++ b/pkg/shell/frames.jsx @@ -0,0 +1,215 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +/* This is the React component that renders all the iframes for the + pages. + + We can't let React itself manipulate the iframe DOM elements, + unfortunately, for two reasons: + + - We need to be super careful when setting the "src" attribute of + an iframe element. Otherwise we get spurious browsing history + entries that cause the Back button of browsers to behave + erratically. + + - At least Chromium 128.0.6613.137 crashes when our iframe elements + are removed from the DOM. + + Thus, we use a giant useEffect hook to reimplement the incremental + DOM updates that React would do for us. +*/ + +import React, { useRef, useEffect } from 'react'; + +function poll_frame_ready(state, frame, elt, count, setupFrameWindow) { + let ready = false; + + try { + if (elt.contentWindow.document && elt.contentWindow.document.body) { + ready = (elt.contentWindow.location.href != "about:blank" && + elt.contentWindow.document.body.offsetWidth > 0 && + elt.contentWindow.document.body.offsetHeight > 0); + } + } catch (ex) { + ready = true; + } + + if (!count) + count = 0; + + count += 1; + if (count > 50) + ready = true; + + if (ready) { + if (!frame.ready) { + frame.ready = true; + state.update(); + } + + if (elt.contentWindow && setupFrameWindow) + setupFrameWindow(elt.contentWindow); + + if (elt.contentDocument && elt.contentDocument.documentElement) { + elt.contentDocument.documentElement.lang = state.config.language; + if (state.config.language_direction) + elt.contentDocument.documentElement.dir = state.config.language_direction; + } + } else { + window.setTimeout(function() { + poll_frame_ready(state, frame, elt, count + 1, setupFrameWindow); + }, 100); + } +} + +export const Frames = ({ state, idle_state, hidden }) => { + const content_ref = useRef(null); + const { frames, current_frame } = state; + + useEffect(() => { + const content = content_ref.current; + if (!content) + return; + + const free_iframes = []; + + function iframe_remove(elt) { + // XXX - chromium crashes somewhere down the line when + // removing iframes here. So we strip them of their + // attributes, put them on a list, and reuse them + // eventually. + state.router.unregister_name(elt.getAttribute('name')); + elt.removeAttribute('name'); + elt.removeAttribute('title'); + elt.removeAttribute('src'); + elt.removeAttribute('data-host'); + elt.removeAttribute("data-ready"); + elt.removeAttribute("data-loaded"); + elt.removeAttribute('class'); + elt.style.display = "none"; + free_iframes.push(elt); + } + + function iframe_new(name) { + let elt = free_iframes.shift(); + if (!elt) { + elt = document.createElement("iframe"); + elt.setAttribute("name", name); + elt.style.display = "none"; + content.appendChild(elt); + } else { + elt.setAttribute("name", name); + elt.contentWindow.name = name; + } + return elt; + } + + const iframes_by_name = {}; + + for (const c of content.children) { + if (c.nodeName == "IFRAME") { + if (c.getAttribute('name')) + iframes_by_name[c.getAttribute('name')] = c; + else + free_iframes.push(c); + } + } + + // Remove obsolete iframes + for (const name in iframes_by_name) { + if (!frames[name] || frames[name].url == null) + iframe_remove(iframes_by_name[name]); + } + + // Add new and update existing iframes + for (const name in frames) { + const frame = frames[name]; + if (!frame.url) + continue; + + let iframe = iframes_by_name[name]; + + if (!iframe) { + iframe = iframe_new(name); + iframe.setAttribute("class", "container-frame"); + iframe.setAttribute("data-host", frame.host); + } + + if (iframe.getAttribute("title") != frame.title) + iframe.setAttribute("title", frame.title); + + if (frame.ready && iframe.getAttribute("data-ready") == null) + iframe.setAttribute("data-ready", "1"); + else if (!frame.ready && iframe.getAttribute("data-ready")) + iframe.removeAttribute("data-ready"); + + if (frame.loaded && iframe.getAttribute("data-loaded") == null) + iframe.setAttribute("data-loaded", "1"); + else if (!frame.loaded && iframe.getAttribute("data-loaded")) + iframe.removeAttribute("data-loaded"); + + const src = frame.url + "#" + frame.hash; + + if (iframe.getAttribute('src') != src) { + if (iframe.contentWindow) { + // This prevents the browser from creating a new + // history entry. It would do that whenever the "src" + // of a frame is changed and the window location is + // not consistent with the new "src" value. + // + // This matters when a "jump" command changes both + // the current frame and the hash of the newly + // current frame. + iframe.contentWindow.location.replace(src); + } + iframe.setAttribute('src', src); + + poll_frame_ready(state, frame, iframe, 0, win => idle_state.setupIdleResetEventListeners(win)); + } + + iframe.style.display = (!hidden && frame == current_frame) ? "block" : "none"; + + // This makes the initial "about:blank" document of the + // iframe dark if necessary, to avoid some flickering. + // + // NOTE: This works well with Chrome, but not with + // Firefox, which seems to create a couple of new + // documentElements as time goes on, and they all start + // out white. + if (!iframes_by_name[name] && iframe.contentDocument.documentElement) { + const style = localStorage.getItem('shell:style') || 'auto'; + if ((window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches && + style === "auto") || + style === "dark") { + // --pf-v5-global--BackgroundColor--dark-300 + iframe.contentDocument.documentElement.style.background = '#1b1d21'; + } else { + iframe.contentDocument.documentElement.style.background = 'white'; + } + } + } + }); + + return
; +}; diff --git a/pkg/shell/hosts.jsx b/pkg/shell/hosts.jsx index d8b23cb788b5..57f251550267 100644 --- a/pkg/shell/hosts.jsx +++ b/pkg/shell/hosts.jsx @@ -15,11 +15,10 @@ import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip"; import 'polyfills'; import { CockpitNav, CockpitNavItem } from "./nav.jsx"; -import { HostModal } from "./hosts_dialog.jsx"; -import { useLoggedInUser } from "hooks"; +import { build_href, split_connection_string } from "./util.jsx"; +import { add_host, edit_host } from "./hosts_dialog.jsx"; const _ = cockpit.gettext; -const hosts_sel = document.getElementById("nav-hosts"); class HostsSelector extends React.Component { constructor() { @@ -29,10 +28,12 @@ class HostsSelector extends React.Component { } componentDidMount() { + const hosts_sel = document.getElementById("nav-hosts"); hosts_sel.appendChild(this.el); } componentWillUnmount() { + const hosts_sel = document.getElementById("nav-hosts"); hosts_sel.removeChild(this.el); } @@ -53,12 +54,10 @@ function HostLine({ host, user }) { } // top left navigation element when host switching is disabled -export const CockpitCurrentHost = ({ machine }) => { - const user_info = useLoggedInUser(); - +export const CockpitCurrentHost = ({ current_user, machine }) => { return (
- +
); }; @@ -72,9 +71,7 @@ export class CockpitHosts extends React.Component { opened: false, editing: false, current_user: "", - current_key: props.machine.key, - show_modal: false, - edit_machine: null, + current_key: props.state.current_machine.key, }; this.toggleMenu = this.toggleMenu.bind(this); @@ -92,10 +89,10 @@ export class CockpitHosts extends React.Component { } static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.machine.key !== prevState.current_key) { + if (nextProps.state.current_machine.key !== prevState.current_key) { document.getElementById(nextProps.selector).classList.toggle("interact", false); return { - current_key: nextProps.machine.key, + current_key: nextProps.state.current_machine.key, opened: false, editing: false, }; @@ -116,12 +113,26 @@ export class CockpitHosts extends React.Component { }); } - onAddNewHost() { - this.setState({ show_modal: true }); + async onAddNewHost() { + await add_host(this.props.host_modal_state); + } + + async onHostEdit(event, machine) { + await edit_host(this.props.host_modal_state, this.props.state, machine); } - onHostEdit(event, machine) { - this.setState({ show_modal: true, edit_machine: machine }); + async onHostSwitch(machine) { + const { state } = this.props; + + // We could launch the connection dialogs here and not jump at + // all when the login fails (or is cancelled), but the + // traditional behavior is to jump and then try to connect. + + const connection_string = machine.connection_string; + const parts = split_connection_string(connection_string); + const addr = build_href({ host: parts.address }); + state.jump(addr); + state.ensure_connection(); } onEditHosts() { @@ -129,17 +140,20 @@ export class CockpitHosts extends React.Component { } onRemove(event, machine) { + const { state } = this.props; + const { current_machine } = state; + event.preventDefault(); - if (this.props.machine === machine) { + if (current_machine === machine) { // Removing machine underneath ourself - jump to localhost - const addr = this.props.hostAddr({ host: "localhost" }, true); - this.props.jump(addr); + const addr = build_href({ host: "localhost" }); + state.jump(addr); } - if (this.props.machines.list.length <= 2) + if (state.machines.list.length <= 2) this.setState({ editing: false }); - this.props.machines.change(machine.key, { visible: false }); + state.machines.change(machine.key, { visible: false }); } filterHosts(host, term) { @@ -164,23 +178,25 @@ export class CockpitHosts extends React.Component { // 1. It does not change the arrow when opened/closed // 2. It closes the dropdown even when trying to search... and cannot tell it not to render() { - const hostAddr = this.props.hostAddr; + const { state } = this.props; + const { current_machine } = state; + const editing = this.state.editing; const groups = [{ name: _("Hosts"), - items: this.props.machines.list, + items: state.machines.list, }]; const render = (m, term) => this.onHostSwitch(m)} actions={<> @@ -190,80 +206,61 @@ export class CockpitHosts extends React.Component { } />; - const label = this.props.machine.label || ""; - const user = this.props.machine.user || this.state.current_user; + const label = current_machine.label || ""; + const user = current_machine.user || this.state.current_user; const add_host_action = ; return ( - <> -
-
- -
- - { this.state.opened && - - - true} - filtering={this.filterHosts} - current={label} - jump={() => console.error("internal error: jump not supported in hosts selector")} - /> -
- {this.props.machines.list.length > 1 && } - {add_host_action} -
-
-
- } + +
- {this.state.show_modal && - this.setState({ show_modal: false, edit_machine: null })} - address={this.state.edit_machine ? this.state.edit_machine.address : null} - caller_callback={this.state.edit_machine - ? (new_connection_string) => { - const parts = this.props.machines.split_connection_string(new_connection_string); - if (this.state.edit_machine == this.props.machine && parts.address != this.state.edit_machine.address) { - const addr = this.props.hostAddr({ host: parts.address }, true); - this.props.jump(addr); - } - return Promise.resolve(); - } - : null } /> + + { this.state.opened && + + + true} + filtering={this.filterHosts} + current={label} + jump={() => console.error("internal error: jump not supported in hosts selector")} + /> +
+ {state.machines.list.length > 1 && } + {add_host_action} +
+
+
} - +
); } } CockpitHosts.propTypes = { - machine: PropTypes.object.isRequired, - machines: PropTypes.object.isRequired, + state: PropTypes.object.isRequired, + host_modal_state: PropTypes.object.isRequired, selector: PropTypes.string.isRequired, - hostAddr: PropTypes.func.isRequired, - jump: PropTypes.func.isRequired, }; diff --git a/pkg/shell/hosts_dialog.jsx b/pkg/shell/hosts_dialog.jsx index 45d3941e1f6e..372bb4822746 100644 --- a/pkg/shell/hosts_dialog.jsx +++ b/pkg/shell/hosts_dialog.jsx @@ -43,8 +43,107 @@ import { OutlinedQuestionCircleIcon } from "@patternfly/react-icons"; import { FormHelper } from "cockpit-components-form-helper"; import { ModalError } from "cockpit-components-inline-notification.jsx"; +import { build_href, split_connection_string, generate_connection_string } from "./util.jsx"; + const _ = cockpit.gettext; +export const HostModalState = () => { + function set_props(props, callback) { + self.modal_properties = props; + self.modal_callback = callback; + self.dispatchEvent("changed"); + } + + function close_modal() { + set_props(null, null); + } + + function show_modal(properties) { + return new Promise((resolve, reject) => { + set_props(properties, result => { resolve(result); return Promise.resolve() }); + }); + } + + const self = { + state: null, + + show_modal, + close_modal, + }; + + cockpit.event_target(self); + return self; +}; + +export async function add_host(state) { + await state.show_modal({ }); +} + +export async function edit_host(state, shell_state, machine) { + const { current_machine } = shell_state; + const connection_string = await state.show_modal({ address: machine.address }); + if (connection_string) { + const parts = split_connection_string(connection_string); + const addr = build_href({ host: parts.address }); + if (machine == current_machine && parts.address != machine.address) { + shell_state.loader.connect(parts.address); + shell_state.jump(addr); + } + } +} + +export async function connect_host(state, shell_state, machine) { + // We need to trigger the loader for machines that already + // have state "connected". The state of a machine object + // survives a full shell reload, but the loader of course has + // no channel open for it yet. The bridge likely has the SSH + // connection still open, so the loader can do its job right + // away, like triggering the packages reload. + // + // "localhost" is a special case: we can always connect the + // loader without any extra credentials and we never want to + // show any dialogs for it. + // + if (machine.connection_string == "localhost" || + machine.state == "connected" || + machine.state == "connecting") { + shell_state.loader.connect(machine.address); + return machine.connection_string; + } + + let connection_string = null; + + if (machine.problem && codes[machine.problem]) { + // trouble shooting + connection_string = await state.show_modal({ + address: machine.address, + template: codes[machine.problem], + }); + } else { + // Try to connect without any dialog + try { + await try2Connect(shell_state.machines, machine.connection_string); + connection_string = machine.connection_string; + } catch (err) { + // continue with troubleshooting in the dialog + connection_string = await state.show_modal({ + address: machine.address, + template: codes[err.problem] || "change-port", + error_options: err, + }); + } + } + + if (connection_string) { + // make the rest of the shell aware that the machine is now connected + const parts = split_connection_string(connection_string); + shell_state.loader.connect(parts.address); + shell_state.update(); + } + + return connection_string; +} + export const codes = { "no-cockpit": "not-supported", "not-supported": "not-supported", @@ -107,7 +206,7 @@ class AddMachine extends React.Component { let address_parts = null; if (this.props.full_address) - address_parts = this.props.machines_ins.split_connection_string(this.props.full_address); + address_parts = split_connection_string(this.props.full_address); let host_address = ""; let host_user = ""; @@ -124,6 +223,8 @@ class AddMachine extends React.Component { old_machine = props.machines_ins.lookup(props.old_address); if (old_machine) color = this.rgb2Hex(old_machine.color); + if (old_machine && !old_machine.visible) + old_machine = null; this.state = { user: host_user || "", @@ -175,9 +276,9 @@ class AddMachine extends React.Component { } onAddHost() { - const parts = this.props.machines_ins.split_connection_string(this.state.address); + const parts = split_connection_string(this.state.address); // user in "User name:" field wins over user in connection string - const address = this.props.machines_ins.generate_connection_string(this.state.user || parts.user, parts.port, parts.address); + const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address); if (this.onAddressChange()) return; @@ -199,9 +300,9 @@ class AddMachine extends React.Component { this.setState({ inProgress: true }); this.props.setGoal(() => { - const parts = this.props.machines_ins.split_connection_string(this.state.address); + const parts = split_connection_string(this.state.address); // user in "User name:" field wins over user in connection string - const address = this.props.machines_ins.generate_connection_string(this.state.user || parts.user, parts.port, parts.address); + const address = generate_connection_string(this.state.user || parts.user, parts.port, parts.address); return new Promise((resolve, reject) => { this.props.machines_ins.add(address, this.state.color) @@ -222,15 +323,16 @@ class AddMachine extends React.Component { }); }); - this.props.run(this.props.try2Connect(address), ex => { + this.props.run(try2Connect(this.props.machines_ins, address), ex => { if (ex.problem === "no-host") { let host_id_port = address; let port = "22"; const port_index = host_id_port.lastIndexOf(":"); - if (port_index === -1) + if (port_index === -1) { host_id_port = address + ":22"; - else + } else { port = host_id_port.substr(port_index + 1); + } ex.message = cockpit.format(_("Unable to contact the given host $0. Make sure it has ssh running on port $1, or specify another port in the address."), host_id_port, port); ex.problem = "not-found"; @@ -313,11 +415,9 @@ class MachinePort extends React.Component { onChangePort() { const promise = new Promise((resolve, reject) => { - const parts = this.props.machines_ins.split_connection_string(this.props.full_address); + const parts = split_connection_string(this.props.full_address); parts.port = this.state.port; - const address = this.props.machines_ins.generate_connection_string(parts.user, - parts.port, - parts.address); + const address = generate_connection_string(parts.user, parts.port, parts.address); const self = this; function update_host(ex) { @@ -326,7 +426,7 @@ class MachinePort extends React.Component { .then(() => { // We failed before so try to connect again now that the machine is saved if (ex) { - self.props.try2Connect(address) + try2Connect(this.props.machines_ins, address) .then(self.props.complete) .catch(reject); } else { @@ -336,7 +436,7 @@ class MachinePort extends React.Component { .catch(ex => reject(cockpit.format(_("Failed to edit machine: $0"), cockpit.message(ex)))); } - this.props.try2Connect(address) + try2Connect(this.props.machines_ins, address) .then(update_host) .catch(ex => { // any other error means progress, so save @@ -413,7 +513,7 @@ class HostKey extends React.Component { match_problem = "unknown-hostkey"; } - this.props.try2Connect(this.props.full_address, options) + try2Connect(this.props.machines_ins, this.props.full_address, options) .then(this.props.complete) .catch(ex => { if (ex.problem !== match_problem) { @@ -439,7 +539,7 @@ class HostKey extends React.Component { } this.props.run(q.then(() => { - return this.props.try2Connect(this.props.full_address, {}) + return try2Connect(this.props.machines_ins, this.props.full_address, {}) .catch(ex => { if ((ex.problem == "invalid-hostkey" || ex.problem == "unknown-hostkey") && machine && !machine.on_disk) this.props.machines_ins.change(this.props.full_address, { host_key: null }); @@ -588,7 +688,7 @@ class ChangeAuth extends React.Component { .catch(ex => { this.setState({ inProgress: false }); this.props.setError(ex) }); if (!this.props.error_options || this.props.error_options["auth-method-results"] === null) { - this.props.try2Connect(this.props.full_address) + try2Connect(this.props.machines_ins, this.props.full_address) .then(this.props.complete) .catch(ex => { this.setState({ inProgress: false }); @@ -664,7 +764,7 @@ class ChangeAuth extends React.Component { login() { const options = {}; - const user = this.props.machines_ins.split_connection_string(this.props.full_address).user || ""; + const user = split_connection_string(this.props.full_address).user || ""; const do_key_password_change = this.state.auto_login && this.state.default_ssh_key.unaligned_passphrase; let custom_password_error = ""; @@ -719,7 +819,7 @@ class ChangeAuth extends React.Component { this.props.run(this.maybe_unlock_key() .then(() => { - return this.props.try2Connect(this.props.full_address, options) + return try2Connect(this.props.machines_ins, this.props.full_address, options) .then(() => { if (machine) return this.props.machines_ins.change(machine.address, { user }); @@ -788,8 +888,8 @@ class ChangeAuth extends React.Component { const luser = this.state.user.name; const lhost = lmach ? lmach.label || lmach.address : "localhost"; const afile = "~/.ssh/authorized_keys"; - const ruser = this.props.machines_ins.split_connection_string(this.props.full_address).user || this.state.user.name; - const rhost = this.props.machines_ins.split_connection_string(this.props.full_address).address; + const ruser = split_connection_string(this.props.full_address).user || this.state.user.name; + const rhost = split_connection_string(this.props.full_address).address; if (!this.state.default_ssh_key.exists) { auto_text = _("Create a new SSH key and authorize it"); auto_details = <> @@ -902,7 +1002,32 @@ class ChangeAuth extends React.Component { } } -export class HostModal extends React.Component { +function try2Connect(machines_ins, address, options) { + return new Promise((resolve, reject) => { + const conn_options = { ...options, payload: "echo", host: address }; + + conn_options["init-superuser"] = get_init_superuser_for_options(conn_options); + + const machine = machines_ins.lookup(address); + if (machine && machine.host_key && !machine.on_disk) { + conn_options['temp-session'] = false; // Compatibility option + conn_options.session = 'shared'; + conn_options['host-key'] = machine.host_key; + } + + const client = cockpit.channel(conn_options); + client.send("x"); + client.addEventListener("message", () => { + resolve(); + client.close(); + }); + client.addEventListener("close", (event, options) => { + reject(options); + }); + }); +} + +class HostModalInner extends React.Component { constructor(props) { super(props); @@ -910,7 +1035,7 @@ export class HostModal extends React.Component { current_template: this.props.template || "add-machine", address: full_address(props.machines_ins, props.address), old_address: full_address(props.machines_ins, props.address), - error_options: null, + error_options: this.props.error_options, dialogError: "", // Error to be shown in the modal }; @@ -918,7 +1043,6 @@ export class HostModal extends React.Component { this.addressOrLabel = this.addressOrLabel.bind(this); this.changeContent = this.changeContent.bind(this); - this.try2Connect = this.try2Connect.bind(this); this.setGoal = this.setGoal.bind(this); this.setError = this.setError.bind(this); this.setAddress = this.setAddress.bind(this); @@ -928,40 +1052,19 @@ export class HostModal extends React.Component { addressOrLabel() { const machine = this.props.machines_ins.lookup(this.state.address); - let host = this.props.machines_ins.split_connection_string(this.state.address).address; + let host = split_connection_string(this.state.address).address; if (machine && machine.label) host = machine.label; return host; } - changeContent(template, error_options) { + changeContent(template, error_options, with_error_message) { if (this.state.current_template !== template) - this.setState({ current_template: template, error_options }); - } - - try2Connect(address, options) { - return new Promise((resolve, reject) => { - const conn_options = { ...options, payload: "echo", host: address }; - - conn_options["init-superuser"] = get_init_superuser_for_options(conn_options); - - const machine = this.props.machines_ins.lookup(address); - if (machine && machine.host_key && !machine.on_disk) { - conn_options['temp-session'] = false; // Compatibility option - conn_options.session = 'shared'; - conn_options['host-key'] = machine.host_key; - } - - const client = cockpit.channel(conn_options); - client.send("x"); - client.addEventListener("message", () => { - resolve(); - client.close(); - }); - client.addEventListener("close", (event, options) => { - reject(options); + this.setState({ + current_template: template, + error_options, + dialogError: with_error_message ? cockpit.message(error_options) : null, }); - }); } complete() { @@ -975,7 +1078,7 @@ export class HostModal extends React.Component { this.promise_callback = callback; } - setError(error) { + setError(error, keep_message_on_change) { if (error === null) return this.setState({ dialogError: null }); @@ -984,7 +1087,7 @@ export class HostModal extends React.Component { template = codes[error.problem]; if (template && this.state.current_template !== template) - this.changeContent(template, error); + this.changeContent(template, error, keep_message_on_change); else this.setState({ error_options: error, dialogError: cockpit.message(error) }); } @@ -1037,11 +1140,15 @@ export class HostModal extends React.Component { host: this.addressOrLabel(), full_address: this.state.address, old_address: this.state.old_address, - address_data: this.props.machines_ins.split_connection_string(this.state.address), + address_data: split_connection_string(this.state.address), error_options: this.state.error_options, dialogError: this.state.dialogError, machines_ins: this.props.machines_ins, - onClose: this.props.onClose, + onClose: () => { + if (this.props.caller_cancelled) + this.props.caller_cancelled(); + this.props.onClose(); + }, run: this.run, setGoal: this.setGoal, setError: this.setError, @@ -1066,10 +1173,21 @@ export class HostModal extends React.Component { } } -HostModal.propTypes = { +HostModalInner.propTypes = { machines_ins: PropTypes.object.isRequired, onClose: PropTypes.func.isRequired, caller_callback: PropTypes.func, address: PropTypes.string, template: PropTypes.string, }; + +export const HostModal = ({ state, machines }) => { + if (!state.modal_properties) + return null; + + return state.close_modal()} + {...state.modal_properties} + caller_callback={state.modal_callback} + caller_cancelled={() => state.modal_callback(null)} />; +}; diff --git a/pkg/shell/idle.jsx b/pkg/shell/idle.jsx new file mode 100644 index 000000000000..673e3cfb9af3 --- /dev/null +++ b/pkg/shell/idle.jsx @@ -0,0 +1,125 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +/* Session timing out after inactivity */ + +import cockpit from "cockpit"; + +import React from 'react'; +import { Modal } from "@patternfly/react-core/dist/esm/components/Modal/index.js"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; + +const _ = cockpit.gettext; + +export const IdleTimeoutState = () => { + const final_countdown_secs = 30; + const standard_login = window.localStorage['standard-login']; + + let final_countdown_timer = -1; + let session_timeout = 0; + let current_idle_time = 0; + + const self = { + final_countdown: false, + }; + + function update() { + self.dispatchEvent("update"); + } + + cockpit.event_target(self); + + function idleTick() { + current_idle_time += 5000; + if (self.final_countdown === false && current_idle_time >= session_timeout - final_countdown_secs * 1000) { + // It's the final countdown... + self.final_countdown = final_countdown_secs; + final_countdown_timer = window.setInterval(finalCountdownTick, 1000); + update(); + } + } + + function finalCountdownTick() { + self.final_countdown -= 1; + if (self.final_countdown <= 0) + cockpit.logout(true, _("You have been logged out due to inactivity.")); + update(); + } + + function resetTimer(ev) { + if (self.final_countdown === false) + current_idle_time = 0; + } + + function setupIdleResetEventListeners(win) { + // NOTE: This function will be called many many times for a + // given window, not just once. Calling addEventListener + // multiple times is ok here, however, since we always pass + // the exact same listener. + if (session_timeout > 0 && standard_login) { + win.addEventListener("mousemove", resetTimer, false); + win.addEventListener("mousedown", resetTimer, false); + win.addEventListener("keypress", resetTimer, false); + win.addEventListener("touchmove", resetTimer, false); + win.addEventListener("scroll", resetTimer, false); + } + } + + self.setupIdleResetEventListeners = setupIdleResetEventListeners; + + cockpit.dbus(null, { bus: "internal" }).call("/config", "cockpit.Config", "GetUInt", ["Session", "IdleTimeout", 0, 240, 0], []) + .then(result => { + session_timeout = result[0] * 60000; + if (session_timeout > 0 && standard_login) { + setupIdleResetEventListeners(window); + window.setInterval(idleTick, 5000); + } + }) + .catch(e => { + if (e.message.indexOf("GetUInt not available") === -1) + console.warn(e.message); + }); + + self.cancel_final_countdown = function () { + current_idle_time = 0; + self.final_countdown = false; + window.clearInterval(final_countdown_timer); + update(); + }; + + return self; +}; + +export const FinalCountdownModal = ({ state }) => { + if (state.final_countdown === false) + return null; + + return ( + state.cancel_final_countdown()}> + {_("Continue session")} + }> + { cockpit.format(_("You will be logged out in $0 seconds."), state.final_countdown) } + + ); +}; diff --git a/pkg/shell/index.html b/pkg/shell/index.html index 96a76a89d507..a9d734ecb2ca 100644 --- a/pkg/shell/index.html +++ b/pkg/shell/index.html @@ -12,46 +12,10 @@ + - -
- - - - - - - - - - - -
- -
- - -
-
- -
- - -
-
- -