diff --git a/files.js b/files.js index d839c5252c9..cba395c4005 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 ffbc21d3a3f..66549780161 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 Object.values(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 3271f9ce590..00000000000 --- 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 92a2a64254d..d45f93c72df 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 00000000000..bed7108dd9c --- /dev/null +++ b/pkg/shell/frames.jsx @@ -0,0 +1,190 @@ +/* + * 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 these 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. + + - We need to adjust the window and document inside the iframe a bit. + + 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; + + function iframe_remove(elt) { + elt.remove(); + } + + function iframe_new(name) { + const elt = document.createElement("iframe"); + elt.setAttribute("name", name); + elt.style.display = "none"; + content.appendChild(elt); + return elt; + } + + const iframes_by_name = {}; + + for (const c of content.children) { + if (c.nodeName == "IFRAME" && c.getAttribute('name')) { + iframes_by_name[c.getAttribute('name')] = 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 d8b23cb788b..57f25155026 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 45d3941e1f6..44146f11d09 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(self.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 00000000000..673e3cfb9af --- /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 96a76a89d50..a9d734ecb2c 100644 --- a/pkg/shell/index.html +++ b/pkg/shell/index.html @@ -12,46 +12,10 @@ + - -
- - - - - - - - - - - -
- -
- - -
-
- -
- - -
-
- -