-
-
diff --git a/pkg/shell/indexes.jsx b/pkg/shell/indexes.jsx
deleted file mode 100644
index 3ef8aa1fd1b..00000000000
--- a/pkg/shell/indexes.jsx
+++ /dev/null
@@ -1,642 +0,0 @@
-/*
- * This file is part of Cockpit.
- *
- * Copyright (C) 2016 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-dark-theme'; // once per page
-import cockpit from "cockpit";
-import React from "react";
-import { createRoot } from "react-dom/client";
-
-import { CockpitNav, CockpitNavItem, SidebarToggle } from "./nav.jsx";
-import { TopNav } from ".//topnav.jsx";
-import { CockpitHosts, CockpitCurrentHost } from "./hosts.jsx";
-import { codes, HostModal } from "./hosts_dialog.jsx";
-import { EarlyFailure, EarlyFailureReady } from './failures.jsx';
-import { WithDialogs } from "dialogs.jsx";
-
-import * as base_index from "./base_index";
-
-const _ = cockpit.gettext;
-
-function MachinesIndex(index_options, machines, loader) {
- if (!index_options)
- index_options = {};
-
- const root = id => createRoot(document.getElementById(id));
-
- // Document is guaranteed to be loaded at this point.
- const sidebar_toggle_root = root('sidebar-toggle');
- const early_failure_root = root('early-failure');
- const early_failure_ready_root = root('early-failure-ready');
- const topnav_root = root('topnav');
- const hosts_sel_root = root('hosts-sel');
- let host_apps_root = null;
-
- const page_status = { };
- sessionStorage.removeItem("cockpit:page_status");
-
- index_options.navigate = function (state, sidebar) {
- return navigate(state, sidebar);
- };
- index_options.handle_notifications = function (host, page, data) {
- if (data.page_status !== undefined) {
- if (!page_status[host])
- page_status[host] = { };
- page_status[host][page] = data.page_status;
- sessionStorage.setItem("cockpit:page_status", JSON.stringify(page_status));
- // Just for triggering an "updated" event
- machines.overlay(host, { });
- }
- };
-
- const index = base_index.new_index_from_proto(index_options);
-
- /* Restarts */
- index.addEventListener("expect_restart", (ev, host) => loader.expect_restart(host));
-
- /* Disconnection Dialog */
- let watchdog_problem = null;
- index.addEventListener("disconnect", (ev, problem) => {
- watchdog_problem = problem;
- show_disconnected();
- });
-
- index.addEventListener("update", () => {
- update_topbar();
- });
-
- /* Is troubleshooting dialog open */
- let troubleshooting_opened = false;
-
- sidebar_toggle_root.render();
-
- // Focus with skiplinks
- const skiplinks = document.getElementsByClassName("skiplink");
- Array.from(skiplinks).forEach(skiplink => {
- skiplink.addEventListener("click", ev => {
- document.getElementById(ev.target.hash.substring(1)).focus();
- ev.preventDefault();
- });
- });
-
- let current_user = "";
- cockpit.user().then(user => {
- current_user = user.name || "";
- }).catch(exc => console.log(exc));
-
- /* Host switcher enabled? */
- let host_switcher_enabled = false;
- const meta_multihost = document.head.querySelector("meta[name='allow-multihost']");
- if (meta_multihost instanceof HTMLMetaElement && meta_multihost.content == "yes")
- host_switcher_enabled = true;
-
- /* Navigation */
- let ready = false;
- function on_ready() {
- ready = true;
- index.ready();
- }
-
- function preload_frames () {
- for (const m of machines.list)
- index.preload_frames(m, m.manifests);
- }
-
- /* When the machine list is ready we start processing navigation */
- machines.addEventListener("ready", on_ready);
- machines.addEventListener("removed", (ev, machine) => {
- index.frames.remove(machine);
- update_machines();
- });
- ["added", "updated"].forEach(evn => {
- machines.addEventListener(evn, (ev, machine) => {
- if (!machine.visible)
- index.frames.remove(machine);
- else if (machine.problem)
- index.frames.remove(machine);
-
- update_machines();
- preload_frames();
- if (ready)
- navigate();
- });
- });
-
- if (machines.ready)
- on_ready();
-
- function show_disconnected() {
- if (!ready) {
- document.getElementById("early-failure-ready").setAttribute("hidden", "hidden");
- document.getElementById("early-failure").removeAttribute("hidden");
-
- const ca_cert_url = window.sessionStorage.getItem("CACertUrl");
- early_failure_root.render(= 0 && ca_cert_url) ? ca_cert_url : undefined
- } />);
- document.getElementById("main").setAttribute("hidden", "hidden");
- document.body.removeAttribute("hidden");
- return;
- }
-
- const current_frame = index.current_frame();
-
- if (current_frame)
- current_frame.setAttribute("hidden", "hidden");
-
- document.getElementById("early-failure").setAttribute("hidden", "hidden");
- document.getElementById("early-failure-ready").removeAttribute("hidden");
-
- early_failure_ready_root.render(
- );
- }
-
- /* Handles navigation */
- function navigate(state, reconnect) {
- /* If this is a watchdog problem or we are troubleshooting
- * let the dialog handle it */
- if (watchdog_problem || troubleshooting_opened)
- return;
-
- if (!state)
- state = index.retrieve_state();
-
- // Force a redirect to localhost when the host switcher is
- // disabled. That way, people won't accidentally connect to
- // remote machines via URL bookmarks or similar that point to
- // them.
- if (!host_switcher_enabled)
- state.host = "localhost";
-
- let machine = machines.lookup(state.host);
-
- /* No such machine */
- if (!machine) {
- machine = {
- key: state.host,
- address: state.host,
- label: state.host,
- state: "failed",
- problem: "not-found",
- };
-
- /* Asked to reconnect to the machine */
- } else if (!machine.visible) {
- machine.state = "failed";
- machine.problem = "not-found";
- } else if (reconnect) {
- loader.connect(state.host);
- }
-
- const compiled = compile(machine);
- if (machine.manifests && !state.component)
- state.component = choose_component(state, compiled);
-
- update_navbar(machine, state, compiled);
- update_topbar(machine, state, compiled);
- update_frame(machine, state, compiled);
-
- /* Just replace the state, and URL */
- index.jump(state, true);
- }
-
- function choose_component(state, compiled) {
- /* Go for the first item */
- const menu_items = compiled.ordered("menu");
- if (menu_items.length > 0 && menu_items[0])
- return menu_items[0].path;
-
- return "system";
- }
-
- function update_topbar(machine, state, compiled) {
- if (!state)
- state = index.retrieve_state();
-
- if (!machine)
- machine = machines.lookup(state.host);
-
- if (!compiled)
- compiled = compile(machine);
-
- topnav_root.render(
-
-
- );
- }
-
- function update_navbar(machine, state, compiled) {
- if (!state)
- state = index.retrieve_state();
-
- if (!machine)
- machine = machines.lookup(state.host);
-
- if (!machine || machine.state != "connected") {
- if (host_apps_root) {
- host_apps_root.unmount();
- host_apps_root = null;
- }
- return;
- }
-
- if (!compiled)
- compiled = compile(machine);
-
- if (machine.address !== "localhost") {
- document.getElementById("main").style.setProperty('--ct-color-host-accent', machine.color);
- } else {
- // Remove property to fall back to default accent color
- document.getElementById("main").style.removeProperty('--ct-color-host-accent');
- }
-
- const component_manifest = find_component(state, compiled);
-
- // Filtering of navigation by term
- function keyword_filter(item, term) {
- function keyword_relevance(current_best, item) {
- const translate = item.translate || false;
- const weight = item.weight || 0;
- let score;
- let _m = "";
- let best = { score: -1 };
- item.matches.forEach(m => {
- if (translate)
- _m = _(m);
- score = -1;
- // Best score when starts in translate language
- if (translate && _m.indexOf(term) == 0)
- score = 4 + weight;
- // Second best score when starts in English
- else if (m.indexOf(term) == 0)
- score = 3 + weight;
- // Substring consider only when at least 3 letters were used
- else if (term.length >= 3) {
- if (translate && _m.indexOf(term) >= 0)
- score = 2 + weight;
- else if (m.indexOf(term) >= 0)
- score = 1 + weight;
- }
- if (score > best.score) {
- best = { keyword: m, score };
- }
- });
- if (best.score > current_best.score) {
- current_best = { keyword: best.keyword, score: best.score, goto: item.goto || null };
- }
- return current_best;
- }
-
- const new_item = Object.assign({}, item);
- new_item.keyword = { score: -1 };
- if (!term)
- return new_item;
- const best_keyword = new_item.keywords.reduce(keyword_relevance, { score: -1 });
- if (best_keyword.score > -1) {
- new_item.keyword = best_keyword;
- return new_item;
- }
- return null;
- }
-
- // Rendering of separate navigation menu items
- function nav_item(component, term) {
- const active = component_manifest === component.path;
-
- // Parse path
- let path = component.path;
- let hash = component.hash;
- if (component.keyword.goto) {
- if (component.keyword.goto[0] === "/")
- path = component.keyword.goto.substr(1);
- else
- hash = component.keyword.goto;
- }
-
- // Parse page status
- let status = null;
- if (page_status[machine.key])
- status = page_status[machine.key][component.path];
-
- return React.createElement(CockpitNavItem, {
- key: component.label,
- name: component.label,
- active,
- status,
- keyword: component.keyword.keyword,
- term,
- to: index.href({ host: machine.address, component: path, hash }),
- jump: index.jump,
- });
- }
-
- const groups = [
- {
- name: _("Apps"),
- items: compiled.ordered("dashboard"),
- }, {
- name: _("System"),
- items: compiled.ordered("menu"),
- }, {
- name: _("Tools"),
- items: compiled.ordered("tools"),
- }
- ].filter(i => i.items.length > 0);
-
- if (compiled.items.apps && groups.length === 3)
- groups[0].action = { label: _("Edit"), path: index.href({ host: machine.address, component: compiled.items.apps.path }) };
-
- if (!host_apps_root)
- host_apps_root = root('host-apps');
- host_apps_root.render(
- React.createElement(CockpitNav, {
- groups,
- selector: "host-apps",
- item_render: nav_item,
- filtering: keyword_filter,
- sorting: (a, b) => { return b.keyword.score - a.keyword.score },
- current: state.component,
- jump: index.jump,
- }));
-
- update_machines(state, machine);
- }
-
- function update_machines(state, machine) {
- if (!state)
- state = index.retrieve_state();
-
- if (!machine)
- machine = machines.lookup(state.host);
-
- // deprecation transition period: show existing remote hosts, but disable adding new ones
- if (host_switcher_enabled) {
- hosts_sel_root.render(
- React.createElement(CockpitHosts, {
- machine: machine || {},
- machines,
- selector: "nav-hosts",
- hostAddr: index.href,
- jump: index.jump,
- }));
- } else {
- hosts_sel_root.render(React.createElement(CockpitCurrentHost, { machine: machine || {} }));
- }
- }
-
- function update_title(label, machine) {
- if (label)
- label += " - ";
- else
- label = "";
- let suffix = index.default_title;
-
- if (machine) {
- if (machine.address === "localhost") {
- const compiled = compile(machine);
- if (compiled.ordered("menu").length || compiled.ordered("tools").length)
- suffix = (machine.user || current_user) + "@" + machine.label;
- } else {
- suffix = (machine.user || current_user) + "@" + machine.label;
- }
- }
-
- document.title = label + suffix;
- }
-
- function find_component(state, compiled) {
- let component = state.component;
- // If `state.component` is not known to any manifest, find where it comes from
- if (compiled.items[state.component] === undefined) {
- let s = state.component;
- while (s && compiled.items[s] === undefined)
- s = s.substring(0, s.lastIndexOf("/"));
- component = s;
- }
-
- // Still don't know where it comes from, check for parent
- if (!component) {
- const comp = cockpit.manifests[state.component];
- if (comp && comp.parent)
- return comp.parent.component;
- }
-
- return component;
- }
-
- let troubleshoot_dialog_root = null;
-
- function update_frame(machine, state, compiled) {
- function render_troubleshoot() {
- troubleshooting_opened = true;
- const template = codes[machine.problem] || "change-port";
- if (!troubleshoot_dialog_root)
- troubleshoot_dialog_root = root('troubleshoot-dialog');
- troubleshoot_dialog_root.render(React.createElement(HostModal, {
- template,
- address: machine.address,
- machines_ins: machines,
- onClose: () => {
- troubleshoot_dialog_root.unmount();
- troubleshoot_dialog_root = null;
- troubleshooting_opened = false;
- navigate(null, true);
- }
- }));
- }
-
- let current_frame = index.current_frame();
-
- if (machine.state != "connected") {
- if (current_frame)
- current_frame.setAttribute("hidden", "hidden");
- current_frame = null;
- index.current_frame(current_frame);
-
- 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 {
- 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;
-
- document.querySelector("#early-failure-ready").removeAttribute("hidden");
- early_failure_ready_root.render(
- );
-
- update_title(null, machine);
-
- /* Fall through when connecting, and allow frame to load at same time */
- if (!connecting)
- return;
- }
-
- let hash = state.hash;
- let component = state.component;
-
- /* Old cockpit packages, used to be in shell/shell.html */
- if (machine && compiled.compat) {
- const compat = compiled.compat[component];
- if (compat) {
- component = "shell/shell";
- hash = compat;
- }
- }
-
- const frame = component ? index.frames.lookup(machine, component, hash) : undefined;
- if (frame != current_frame) {
- if (current_frame) {
- current_frame.style.display = "none";
- // Reset 'data-active' only on the same host
- if (frame.getAttribute('data-host') === current_frame.getAttribute('data-host'))
- current_frame.setAttribute('data-active', 'false');
- }
- index.current_frame(frame);
- }
-
- if (machine.state == "connected") {
- document.querySelector("#early-failure-ready").setAttribute("hidden", "hidden");
- frame.style.display = "block";
- frame.setAttribute('data-active', 'true');
- frame.removeAttribute("hidden");
-
- const component_manifest = find_component(state, compiled);
- const item = compiled.items[component_manifest];
- const label = item ? item.label : "";
- update_title(label, machine);
- if (label)
- frame.setAttribute('title', label);
- }
- }
-
- function compatibility(machine) {
- if (!machine.manifests || machine.address === "localhost")
- return null;
-
- const shell = machine.manifests.shell || { };
- const menu = shell.menu || { };
- const tools = shell.tools || { };
-
- const mapping = { };
-
- /* The following were included in shell/shell.html in old versions */
- if ("_host_" in menu)
- mapping["system/host"] = "/server";
- if ("_init_" in menu)
- mapping["system/init"] = "/services";
- if ("_network_" in menu)
- mapping["network/interfaces"] = "/networking";
- if ("_storage_" in menu)
- mapping["storage/devices"] = "/storage";
- if ("_users_" in tools)
- mapping["users/local"] = "/accounts";
-
- /* For Docker we have to guess ... some heuristics */
- if ("_storage_" in menu || "_init_" in menu)
- mapping["docker/containers"] = "/containers";
-
- return mapping;
- }
-
- function compile(machine) {
- const compiled = base_index.new_compiled();
- compiled.load(machine.manifests, "tools");
- compiled.load(machine.manifests, "dashboard");
- compiled.load(machine.manifests, "menu");
- compiled.compat = compatibility(machine);
- return compiled;
- }
-
- cockpit.transport.wait(function() {
- index.start();
- });
-}
-
-function message_queue(event) {
- window.messages.push(event);
-}
-
-/* When we're being loaded into the index window we have additional duties */
-if (document.documentElement.classList.contains("index-page")) {
- /* Indicates to child frames that we are a cockpit1 router frame */
- window.name = "cockpit1";
-
- /* The same thing as above, but compatibility with old cockpit */
- window.options = { sink: true, protocol: "cockpit1" };
-
- /* Tell the pages about our features. */
- window.features = {
- navbar_is_for_current_machine: true
- };
-
- /* While the index is initializing, snag any messages we receive from frames */
- window.messages = [];
-
- window.messages.cancel = function() {
- window.removeEventListener("message", message_queue, false);
- window.messages = null;
- };
-
- let language = document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1");
- if (!language)
- language = navigator.language.toLowerCase(); // Default to Accept-Language header
-
- document.documentElement.lang = language;
- if (cockpit.language_direction)
- document.documentElement.dir = cockpit.language_direction;
-
- window.addEventListener("message", message_queue, false);
-}
-
-export function machines_index(options, machines_ins, loader) {
- return new MachinesIndex(options, machines_ins, loader);
-}
diff --git a/pkg/shell/machines/machines.js b/pkg/shell/machines/machines.js
index ad4abba2227..a3d78c7b9fd 100644
--- a/pkg/shell/machines/machines.js
+++ b/pkg/shell/machines/machines.js
@@ -2,6 +2,8 @@ import cockpit from "cockpit";
import ssh_add_key_sh from "../../lib/ssh-add-key.sh";
+import { split_connection_string, generate_connection_string } from "../util.jsx";
+
const mod = { };
/*
@@ -157,9 +159,9 @@ function Machines() {
if (!machine.address)
machine.address = host;
- machine.connection_string = self.generate_connection_string(machine.user,
- machine.port,
- machine.address);
+ machine.connection_string = generate_connection_string(machine.user,
+ machine.port,
+ machine.address);
if (!machine.label) {
if (host == "localhost" || host == "localhost.localdomain") {
@@ -245,7 +247,7 @@ function Machines() {
};
self.add = function add(connection_string, color) {
- let values = self.split_connection_string(connection_string);
+ let values = split_connection_string(connection_string);
const host = values.address;
values = {
@@ -323,7 +325,7 @@ function Machines() {
};
self.overlay = function overlay(host, values) {
- const address = self.split_connection_string(host).address;
+ const address = split_connection_string(host).address;
const changes = { };
changes[address] = { ...last.overlay[address] };
merge(changes[address], values);
@@ -358,51 +360,10 @@ function Machines() {
});
self.lookup = function lookup(address) {
- const parts = self.split_connection_string(address);
+ const parts = split_connection_string(address);
return machines[parts.address || "localhost"] || null;
};
- self.generate_connection_string = function (user, port, addr) {
- let address = addr;
- if (user)
- address = user + "@" + address;
-
- if (port)
- address = address + ":" + port;
-
- return address;
- };
-
- self.split_connection_string = function(conn_to) {
- const parts = {};
- let user_spot = -1;
- let port_spot = -1;
-
- if (conn_to) {
- if (conn_to.substring(0, 6) === "ssh://")
- conn_to = conn_to.substring(6);
- user_spot = conn_to.lastIndexOf('@');
- port_spot = conn_to.lastIndexOf(':');
- }
-
- if (user_spot > 0) {
- parts.user = conn_to.substring(0, user_spot);
- conn_to = conn_to.substring(user_spot + 1);
- port_spot = conn_to.lastIndexOf(':');
- }
-
- if (port_spot > -1) {
- const port = parseInt(conn_to.substring(port_spot + 1), 10);
- if (!isNaN(port)) {
- parts.port = port;
- conn_to = conn_to.substring(0, port_spot);
- }
- }
-
- parts.address = conn_to;
- return parts;
- };
-
self.close = function close() {
window.removeEventListener("storage", storage);
};
@@ -704,7 +665,7 @@ function Loader(machines, session_only) {
};
self.expect_restart = function expect_restart(host) {
- const parts = machines.split_connection_string(host);
+ const parts = split_connection_string(host);
machines.overlay(parts.address, {
restarting: true,
problem: null
diff --git a/pkg/shell/nav.jsx b/pkg/shell/nav.jsx
index 5e170602a89..86822eac13a 100644
--- a/pkg/shell/nav.jsx
+++ b/pkg/shell/nav.jsx
@@ -9,6 +9,8 @@ import { SearchInput } from "@patternfly/react-core/dist/esm/components/SearchIn
import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip/index.js";
import { ContainerNodeIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InfoCircleIcon } from '@patternfly/react-icons';
+import { build_href } from "./util.jsx";
+
const _ = cockpit.gettext;
export const SidebarToggle = () => {
@@ -114,8 +116,8 @@ export class CockpitNav extends React.Component {
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.current !== prevState.current)
return {
- current: nextProps.current,
search: "",
+ current: nextProps.current,
};
return null;
}
@@ -166,7 +168,6 @@ CockpitNav.propTypes = {
groups: PropTypes.array.isRequired,
selector: PropTypes.string.isRequired,
item_render: PropTypes.func.isRequired,
- current: PropTypes.string.isRequired,
filtering: PropTypes.func.isRequired,
sorting: PropTypes.func.isRequired,
jump: PropTypes.func.isRequired,
@@ -248,3 +249,124 @@ CockpitNavItem.propTypes = {
header: PropTypes.string,
actions: PropTypes.node,
};
+
+export const PageNav = ({ state }) => {
+ const {
+ current_machine,
+ current_manifest_item,
+ current_machine_manifest_items,
+ page_status,
+ } = state;
+
+ if (!current_machine || current_machine.state != "connected") {
+ return null;
+ }
+
+ // Filtering of navigation by term
+ function keyword_filter(item, term) {
+ function keyword_relevance(current_best, item) {
+ const translate = item.translate || false;
+ const weight = item.weight || 0;
+ let score;
+ let _m = "";
+ let best = { score: -1 };
+ item.matches.forEach(m => {
+ if (translate)
+ _m = _(m);
+ score = -1;
+ // Best score when starts in translate language
+ if (translate && _m.indexOf(term) == 0)
+ score = 4 + weight;
+ // Second best score when starts in English
+ else if (m.indexOf(term) == 0)
+ score = 3 + weight;
+ // Substring consider only when at least 3 letters were used
+ else if (term.length >= 3) {
+ if (translate && _m.indexOf(term) >= 0)
+ score = 2 + weight;
+ else if (m.indexOf(term) >= 0)
+ score = 1 + weight;
+ }
+ if (score > best.score) {
+ best = { keyword: m, score };
+ }
+ });
+ if (best.score > current_best.score) {
+ current_best = { keyword: best.keyword, score: best.score, goto: item.goto || null };
+ }
+ return current_best;
+ }
+
+ const new_item = Object.assign({}, item);
+ new_item.keyword = { score: -1 };
+ if (!term)
+ return new_item;
+ const best_keyword = new_item.keywords.reduce(keyword_relevance, { score: -1 });
+ if (best_keyword.score > -1) {
+ new_item.keyword = best_keyword;
+ return new_item;
+ }
+ return null;
+ }
+
+ // Rendering of separate navigation menu items
+ function nav_item(item, term) {
+ const active = current_manifest_item.path === item.path;
+
+ // Parse path
+ let path = item.path;
+ let hash = item.hash;
+ if (item.keyword.goto) {
+ if (item.keyword.goto[0] === "/")
+ path = item.keyword.goto.substr(1);
+ else
+ hash = item.keyword.goto;
+ }
+
+ // Parse page status
+ let status = null;
+ if (page_status[current_machine.key])
+ status = page_status[current_machine.key][item.path];
+
+ return (
+
+ );
+ }
+
+ const groups = [
+ {
+ name: _("Apps"),
+ items: current_machine_manifest_items.ordered("dashboard"),
+ }, {
+ name: _("System"),
+ items: current_machine_manifest_items.ordered("menu"),
+ }, {
+ name: _("Tools"),
+ items: current_machine_manifest_items.ordered("tools"),
+ }
+ ].filter(i => i.items.length > 0);
+
+ if (current_machine_manifest_items.items.apps && groups.length === 3)
+ groups[0].action = {
+ label: _("Edit"),
+ path: build_href({
+ host: current_machine.address,
+ path: current_machine_manifest_items.items.apps.path
+ })
+ };
+
+ return { return b.keyword.score - a.keyword.score }}
+ current={current_manifest_item.path}
+ jump={state.jump} />;
+};
diff --git a/pkg/shell/router.jsx b/pkg/shell/router.jsx
new file mode 100644
index 00000000000..46fd1bfa334
--- /dev/null
+++ b/pkg/shell/router.jsx
@@ -0,0 +1,293 @@
+/*
+ * 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 .
+ */
+
+/* THE MESSAGE ROUTER
+ *
+ * The message router forwards Cockpit protocol messages between the
+ * web socket and the frames.
+ *
+ * It automatically starts processing messages for a frame as soon as
+ * it receives the "init" message from that frame.
+ *
+ * The router needs a "callback" object that it uses to hook into the
+ * rest of the shell. This is provided by the ShellState instance,
+ * and more documentation can be found there.
+ */
+
+import cockpit from "cockpit";
+
+export function Router(callbacks) {
+ 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) {
+ let str = control.location || "";
+ if (str[0] != "/")
+ str = "/" + str;
+ if (control.host)
+ str = "/@" + encodeURIComponent(control.host) + str;
+
+ callbacks.perform_frame_jump_command(child.name, str);
+ }
+
+ function perform_track(child) {
+ let hash = child.location.hash;
+ if (hash.indexOf("#") === 0)
+ hash = hash.substring(1);
+ if (hash === "/")
+ hash = "";
+
+ callbacks.perform_frame_hash_track(child.name, hash);
+ }
+
+ 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);
+
+ perform_track(child);
+
+ 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 inaccessible 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;
+
+ callbacks.frame_is_initialized(child.frameElement.name);
+ }
+ } 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)
+ callbacks.expect_restart(control.host);
+ } else
+ cockpit.hint(control.hint, control);
+ return;
+ } else if (control.command == "oops") {
+ callbacks.show_oops();
+ return;
+ } else if (control.command == "notify") {
+ if (source)
+ callbacks.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.hint = function hint(name, data) {
+ const source = source_by_name[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);
+ }
+ };
+
+ self.unregister_name = (name) => {
+ const source = source_by_name[name];
+ if (source)
+ unregister(source);
+ };
+
+ cockpit.transport.wait(function() {
+ window.addEventListener("message", message_handler, false);
+ });
+}
diff --git a/pkg/shell/shell-modals.jsx b/pkg/shell/shell-modals.jsx
index 9588cf89ae8..ea9b4b67e44 100644
--- a/pkg/shell/shell-modals.jsx
+++ b/pkg/shell/shell-modals.jsx
@@ -167,19 +167,6 @@ export const LangModal = ({ dialogResult }) => {
);
};
-export function TimeoutModal(props) {
- return (
- {_("Continue session")}}
- >
- {props.text}
-
- );
-}
-
export function OopsModal({ dialogResult }) {
return (
.
- */
-
-import '../lib/patternfly/patternfly-5-cockpit.scss';
-
-import { machines } from "./machines/machines.js";
-import * as indexes from "./indexes.jsx";
-
-import "./shell.scss";
-
-const machines_inst = machines.instance();
-const loader = machines.loader(machines_inst);
-
-const options = {
- default_title: "Cockpit",
-};
-
-indexes.machines_index(options, machines_inst, loader);
diff --git a/pkg/shell/shell.jsx b/pkg/shell/shell.jsx
new file mode 100644
index 00000000000..f5ef0b5bd8a
--- /dev/null
+++ b/pkg/shell/shell.jsx
@@ -0,0 +1,198 @@
+/*
+ * 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 .
+ */
+
+import cockpit from "cockpit";
+
+import React from 'react';
+import { createRoot } from "react-dom/client";
+
+import { WithDialogs } from "dialogs.jsx";
+import { useInit, useEvent, useLoggedInUser } from "hooks";
+
+import { TopNav } from "./topnav.jsx";
+import { SidebarToggle, PageNav } from "./nav.jsx";
+import { CockpitHosts, CockpitCurrentHost } from "./hosts.jsx";
+import { HostModalState, HostModal, connect_host } from "./hosts_dialog.jsx";
+import { Frames } from "./frames.jsx";
+import { EarlyFailure, Disconnected, MachineTroubleshoot } from "./failures.jsx";
+
+import { ShellState } from "./state.jsx";
+import { IdleTimeoutState, FinalCountdownModal } from "./idle.jsx";
+
+import 'cockpit-dark-theme'; // once per page
+
+import '../lib/patternfly/patternfly-5-cockpit.scss';
+import "./shell.scss";
+
+const _ = cockpit.gettext;
+
+const SkipLink = ({ focus_id, children }) => {
+ return (
+ {
+ document.getElementById(focus_id).focus();
+ ev.preventDefault();
+ }}>
+ {children}
+
+ );
+};
+
+const Shell = () => {
+ const current_user = useLoggedInUser()?.name || "";
+ const state = useInit(() => ShellState());
+ const idle_state = useInit(() => IdleTimeoutState());
+ const host_modal_state = useInit(() => HostModalState());
+
+ useEvent(state, "update");
+ useEvent(idle_state, "update");
+ useEvent(host_modal_state, "changed");
+
+ useEvent(state, "connect", () => {
+ // We could launch some dialogs here, but the traditional
+ // behavior is to just connect the loader and open the dialogs
+ // from the troubleshoot button.
+ state.loader.connect(state.current_machine.address);
+ });
+
+ const {
+ ready, problem,
+
+ config,
+
+ current_machine,
+ current_manifest_item,
+ } = state;
+
+ if (problem && !ready)
+ return ;
+
+ if (!ready)
+ return null;
+
+ const title_parts = [];
+ if (current_manifest_item.label)
+ title_parts.push(current_manifest_item.label);
+ title_parts.push((current_machine.user || current_user) + "@" + current_machine.label);
+ document.title = title_parts.join(" - ");
+
+ if (idle_state.final_countdown)
+ document.title = "(" + idle_state.final_countdown + ") " + document.title;
+
+ document.documentElement.lang = config.language;
+ if (config.language_direction)
+ document.documentElement.dir = config.language_direction;
+
+ let failure = null;
+ if (problem) {
+ failure = ;
+ } else if (current_machine.state != "connected") {
+ failure = connect_host(host_modal_state, state, current_machine)} />;
+ }
+
+ return (
+
+
+ {_("Skip to content")}
+ {_("Skip main navigation")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { failure &&
+
+ { failure }
+
+ }
+
+
+
+
+
);
+};
+
+function init() {
+ cockpit.translate();
+
+ /* Give us a name. This used to (maybe) indicate at some point to
+ * child frames that we are a cockpit1 router frame. But they
+ * actually check for a "cockpit1:" prefix of their own name.
+ */
+ window.name = "cockpit1";
+
+ /* Tell the pages about our features. */
+ window.features = {
+ navbar_is_for_current_machine: true
+ };
+
+ 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 in the browser console */
+ Object.defineProperties(window, {
+ cockpit: { value: cockpit },
+ zz: {
+ get: function() { return zz_value },
+ set: function(val) { zz_value = val; follow(val) }
+ }
+ });
+
+ const root = createRoot(document.getElementById("shell"));
+ root.render();
+}
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/pkg/shell/shell.scss b/pkg/shell/shell.scss
index f2a067be505..afdf53c9b86 100644
--- a/pkg/shell/shell.scss
+++ b/pkg/shell/shell.scss
@@ -31,6 +31,10 @@
--ct-topnav-background: var(--pf-v5-global--BackgroundColor--dark-100);
}
+#shell {
+ block-size: 100%;
+}
+
.pf-v5-theme-dark {
--ct-topnav-background: var(--pf-v5-global--BackgroundColor--dark-200);
}
@@ -107,7 +111,6 @@ html.index-page body {
}
iframe.container-frame {
- display: none;
border: none;
inline-size: 100%;
}
diff --git a/pkg/shell/state.jsx b/pkg/shell/state.jsx
new file mode 100644
index 00000000000..ca5ee15cdda
--- /dev/null
+++ b/pkg/shell/state.jsx
@@ -0,0 +1,584 @@
+/*
+ * 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 .
+ */
+
+import cockpit from "cockpit";
+
+import { Router } from "./router.jsx";
+import { machines as machines_factory } from "./machines/machines.js";
+import {
+ decode_location, decode_window_location, push_window_location, replace_window_location,
+ compile_manifests, compute_frame_url,
+} from "./util.jsx";
+
+export function ShellState() {
+ /* CONFIG
+ */
+
+ let language = document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1");
+ if (!language)
+ language = navigator.language.toLowerCase(); // Default to Accept-Language header
+
+ const config = {
+ language,
+ language_direction: cockpit.language_direction,
+ host_switcher_enabled: false,
+ };
+
+ /* Host switcher enabled? */
+ const meta_multihost = document.head.querySelector("meta[name='allow-multihost']");
+ if (meta_multihost instanceof HTMLMetaElement && meta_multihost.content == "yes")
+ config.host_switcher_enabled = true;
+
+ /* MACHINES DATABASE AND MANIFEST LOADER
+ *
+ * These are part of the machinery in the basement that maintains
+ * the database of all hosts (including "localhost", and monitors
+ * their manifests.
+ */
+
+ const machines = machines_factory.instance();
+ const loader = machines_factory.loader(machines);
+
+ machines.addEventListener("ready", on_ready);
+
+ machines.addEventListener("removed", (ev, machine) => {
+ remove_machine_frames(machine);
+ });
+ machines.addEventListener("added", (ev, machine) => {
+ preload_machine_frames(machine);
+ });
+ machines.addEventListener("updated", (ev, machine) => {
+ if (!machine.visible || machine.problem)
+ remove_machine_frames(machine);
+ else
+ preload_machine_frames(machine);
+ });
+
+ if (machines.ready)
+ on_ready();
+
+ function on_ready() {
+ if (machines.ready) {
+ self.ready = true;
+ window.addEventListener("popstate", ev => {
+ update();
+ ensure_frame_loaded();
+ ensure_connection();
+ });
+
+ update();
+ ensure_frame_loaded();
+ ensure_connection();
+ }
+ }
+
+ /* WATCH DOGS
+ */
+
+ const watchdog = cockpit.channel({ payload: "null" });
+ watchdog.addEventListener("close", (event, options) => {
+ const watchdog_problem = options.problem || "disconnected";
+ console.warn("transport closed: " + watchdog_problem);
+ self.problem = watchdog_problem;
+ // We might get here real early, before events seem to
+ // work. Let's push the update processing to the event loop.
+ setTimeout(() => update(), 0);
+ });
+
+ 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.has_oops = true;
+ update();
+ }
+ if (old_onerror)
+ return old_onerror(msg, url, line);
+ return false;
+ };
+
+ /* FRAMES
+ *
+ * Frames are created on-demand when navigating to them for the
+ * first time, by calling ensure_frame().
+ *
+ * Once a frame object is created it doesn't change anymore except
+ * for its "ready", "loaded", and "hash" properties.
+ *
+ * The "ready" property starts out false and goes to true once the
+ * corresponding iframe has created the document and window
+ * objects for the actual frame content and you can attach event
+ * handlers to it. The "loaded" property starts out false and goes
+ * true once the code loaded into the frame has sent its "init"
+ * message.
+ *
+ * Removing things (frames) is complicated, as usual. We need to
+ * be able to represent the state "The current frame has been
+ * removed" without any call to update() re-creating it
+ * spontaneously. Thus, a frame has a special "dead" state where
+ * its "url" property is null. Actually clicking on navigation
+ * elements will call the "ensure_frame_loaded" hook, which will
+ * bring the current frame back to life if necessary. This happens
+ * in the "jump" method.
+ */
+
+ const frames = { };
+
+ function ensure_frame(machine, path, hash, title) {
+ /* Never create new frames for machines that are not
+ connected yet. That would open a channel to them (for
+ loading the URL), which woould trigger the bridge to
+ attempt a log in. We want all logins to happen in a
+ single place (in hosts.jsx) so that we can get the
+ options right, and show a warning dialog.
+ */
+ if (machine.address != "localhost" && machine.state !== "connected")
+ return null;
+
+ const name = "cockpit1:" + machine.connection_string + "/" + path;
+ let frame = frames[name];
+
+ if (!frame) {
+ frame = frames[name] = {
+ name,
+ host: machine.address,
+ path,
+ url: compute_frame_url(machine, path),
+ title,
+ ready: false,
+ loaded: false,
+ };
+ }
+
+ frame.hash = hash || "/";
+ return frame;
+ }
+
+ function ensure_frame_loaded () {
+ if (self.current_frame && self.current_frame.url == null) {
+ // Let update() recreate the frame.
+ delete frames[self.current_frame.name];
+ self.current_frame = null;
+ update();
+ }
+ }
+
+ function kill_frame(name) {
+ // Only mark frame as dead, it gets removed for real during
+ // the call to "update".
+ frames[name].url = null;
+ }
+
+ function remove_frame (name) {
+ kill_frame(name);
+ update();
+ }
+
+ function remove_machine_frames (machine) {
+ const names = Object.keys(frames);
+ for (const n of names) {
+ if (frames[n].host == machine.address)
+ kill_frame(n);
+ }
+ update();
+ }
+
+ function preload_machine_frames (machine) {
+ const manifests = machine.manifests;
+ const compiled = compile_manifests(manifests);
+ for (const c in manifests) {
+ const preload = manifests[c].preload;
+ if (preload && preload.length) {
+ for (const p of preload) {
+ const path = (p == "index") ? c : c + "/" + p;
+ const item = compiled.find_path_item(path);
+ ensure_frame(machine, path, null, item.label);
+ }
+ }
+ }
+ update();
+ }
+
+ /* PAGE STATUS
+ *
+ * Page status notifications arrive from the Router (see
+ * below). We also store them in the session storage so that
+ * individual pages have access to all collected statuses.
+ */
+
+ const page_status = { };
+ sessionStorage.removeItem("cockpit:page_status");
+
+ function notify_page_status(host, page, status) {
+ if (!page_status[host])
+ page_status[host] = { };
+ page_status[host][page] = status;
+ sessionStorage.setItem("cockpit:page_status", JSON.stringify(page_status));
+ update();
+ }
+
+ /* ROUTER
+ *
+ * The router is the machinery in our basement that forwards
+ * Cockpit protocol messages between the WebSocket and the
+ * frames. Some messages are also meant for the Shell itself, and
+ * we pass a big object with callback function to the router to
+ * process these and other noteworthy events.
+ */
+
+ const router_callbacks = {
+ /* The router has just processed the "init" message of the
+ * code loaded into the frame named FRAME_NAME.
+ *
+ * We set the "loaded" property to help the tests, and also
+ * tell the frame whether it is visible or not.
+ */
+ frame_is_initialized: function (frame_name) {
+ const frame = frames[frame_name];
+ if (frame) {
+ frame.loaded = true;
+ update();
+ }
+ send_frame_hidden_hint(frame_name);
+ },
+
+ /* The frame named FRAME_NAME wants the shell to jump to
+ * LOCATION.
+ *
+ * Only requests from the current frame are honored. But the
+ * tests also use this extensively for navigation, and might
+ * send messages from the top-most window, which we know is
+ * named "cockpit1".
+ */
+ perform_frame_jump_command: function (frame_name, location) {
+ if (frame_name == "cockpit1" || (self.current_frame && self.current_frame.name == frame_name)) {
+ jump(location);
+ ensure_connection();
+ }
+ },
+
+ /* The frame named FRAME_NAMED has just changed the hash part
+ * of its URL. That's how frames navigate within themselves.
+ *
+ * When the current frame does that, we need to reflect the
+ * hash change in the shell URL as well.
+ */
+ perform_frame_hash_track: function (frame_name, hash) {
+ /* Note that we ignore tracking for old shell code */
+ if (self.current_frame && self.current_frame.name === frame_name &&
+ frame_name && frame_name.indexOf("/shell/shell") === -1) {
+ /* The browser has already pushed an appropriate entry to
+ the history, so let's just replace it with one that
+ includes the right hash.
+ */
+ const location = Object.assign({}, decode_window_location(), { hash });
+ replace_window_location(location);
+ remember_location(location.host, location.path, location.hash);
+ update();
+ }
+ },
+
+ /* A notification has been received from a frame. We only
+ * handle page status notifications, such as the ones that
+ * tell you when software updates are available. PAGE is the
+ * "well-known name" of a page, such as "system",
+ * "network/firewall", or "updates".
+ */
+ handle_notifications: function (host, page, data) {
+ if (data.page_status !== undefined)
+ notify_page_status(host, page, data.page_status);
+ },
+
+ /* One of the frames has experienced a unhandled JavaScript exception.
+ */
+ show_oops: function () {
+ self.has_oops = true;
+ update();
+ },
+
+ /* The host with address HOST has just initiated a restart. We
+ * tell the loader.
+ */
+ expect_restart: function (host) {
+ loader.expect_restart(host);
+ },
+ };
+
+ function send_frame_hidden_hint (frame_name) {
+ const hidden = !self.current_frame || self.current_frame.name != frame_name;
+ router.hint(frame_name, { hidden });
+ }
+
+ const router = new Router(router_callbacks);
+
+ /* NAVIGATION
+ *
+ * The main navigation function, jump(), will change
+ * window.location as requested and then trigger a general
+ * ShellState update. The update processing will look at
+ * window.location and update the various "current_*" properties
+ * of the shell state accordingly. (The update processing might
+ * also change window.location again itself, in order to
+ * canonicalize it.)
+ *
+ * The new location given to jump() can be partial; the missing
+ * pieces are filled in from the browsing history in a (almost)
+ * natural way. If the HOST part is missing, it will be taken from
+ * the current location. If the PATH part is missing, the last
+ * path visited on the given host is used. And if the HASH is
+ * missing, the last one from the given HOST/PATH combination is
+ * used. But only, and this is a historical quirk, when the new
+ * host/path differs from the current host/path. Don't rely on
+ * that, always use "/" as the hash when jumping to the top
+ * sub-page.
+ *
+ * Calling jump() will also make sure that the (newly) current
+ * frame will now be loaded again in the case that it was
+ * explicitly removed earlier. (This also happens when
+ * window.location isn't actually changed by jump().)
+ *
+ * But jump() will never open a new connection to a HOST that is
+ * not yet connected. If you want that, call ensure_connection()
+ * right after jump(). However, it is better to first connect to
+ * the host using the connect_host function from hosts_dialog.jsx
+ * and only call jump() when that has succeeded.
+ *
+ * Calling ensure_connection() will start a user interaction to
+ * open a connection to the host of the current navigation
+ * location, but will not wait for this to be complete.
+ */
+
+ const last_path_for_host = { };
+ const last_hash_for_host_path = { };
+
+ function most_recent_path_for_host(host) {
+ return last_path_for_host[host] || "";
+ }
+
+ function most_recent_hash_for_path(host, path) {
+ if (last_hash_for_host_path[host])
+ return last_hash_for_host_path[host][path] || null;
+ return null;
+ }
+
+ function remember_location(host, path, hash) {
+ last_path_for_host[host] = path;
+ if (!last_hash_for_host_path[host])
+ last_hash_for_host_path[host] = { };
+ last_hash_for_host_path[host][path] = hash;
+ }
+
+ function jump (location) {
+ if (typeof (location) === "string")
+ location = decode_location(location);
+
+ const current = decode_window_location();
+
+ /* Fill in the missing pieces, in order.
+ */
+
+ if (!location.host)
+ location.host = current.host || "localhost";
+
+ if (!location.path)
+ location.path = most_recent_path_for_host(location.host);
+
+ if (!location.hash) {
+ if (location.host != current.host || location.path != current.path)
+ location.hash = most_recent_hash_for_path(location.host, location.path);
+ else
+ console.warn('Shell jump with hash and no frame change. Please use "/" as the hash to jump to the top sub-page.');
+ }
+
+ if (location.host !== current.host ||
+ location.path !== current.pathframe_change ||
+ location.hash !== current.hash) {
+ push_window_location(location);
+ update();
+ ensure_frame_loaded();
+ return true;
+ }
+
+ ensure_frame_loaded();
+ return false;
+ }
+
+ function ensure_connection() {
+ if (self.current_machine) {
+ // Handle localhost right here, we never need user
+ // interactions for it, and it is kind of important to not
+ // mess up connecting to localhost. So we avoid relying on
+ // the bigger machinery for it.
+ //
+ if (self.current_machine.connection_string == "localhost") {
+ loader.connect("localhost");
+ return;
+ }
+
+ self.dispatchEvent("connect");
+ }
+ }
+
+ /* STATE
+ *
+ * Whenever the shell state changes, the "updated" event is
+ * dispatched.
+ *
+ * The main part of the shell state is the information related to
+ * the current navigation location:
+ *
+ * - current_location
+ *
+ * A object with "host", "path", and "hash" fields that reflect
+ * the current location. "hash" does not have the "#" character.
+ *
+ * - current_machine
+ *
+ * The machine object (see machines/machines.js) for the "host"
+ * part of "current_location". This is never null when
+ * "current_location" isn't null. But the machine might not be
+ * connected, and might not have manifests, etc.
+ *
+ * - current_manifest_item
+ *
+ * The manifest item corresponding to the "path" part of
+ * "current_location". This is a piece of the current machines
+ * manifests, from the "menu", "tools", or "dashboard" arrays.
+ *
+ * The item describes the navigation item in the sidebar that gets
+ * highlighted for "path". The correspondence between the two is
+ * not always straightforward. For example, both "network" and
+ * "network/firewall" will have the same item, the one for
+ * "Networking". But "system/logs" has its own item, "Logs",
+ * eventhough it comes from the same package as the "system" path.
+ *
+ * And then, the "metrics" path has the "Overview" item associated
+ * with it, although the two come from different packages.
+ */
+
+ const self = {
+ ready: false,
+ problem: null,
+ has_oops: false,
+
+ config,
+ page_status,
+ frames,
+
+ current_location: null,
+ current_machine: null,
+ current_manifest_item: null,
+ current_machine_manifest_items: null,
+
+ // Methods
+ jump,
+ ensure_connection,
+ remove_frame,
+ most_recent_path_for_host,
+
+ // Access to the inner parts of the machinery, use with
+ // caution.
+ machines,
+ loader,
+ router,
+ };
+
+ cockpit.event_target(self);
+
+ function update() {
+ if (!self.ready || self.problem) {
+ self.dispatchEvent("update");
+ return;
+ }
+
+ const location = decode_window_location();
+
+ // Force a redirect to localhost when the host switcher is
+ // disabled. That way, people won't accidentally connect to
+ // remote machines via URL bookmarks or similar that point to
+ // them.
+ if (!self.config.host_switcher_enabled) {
+ location.host = "localhost";
+ replace_window_location(location);
+ }
+
+ let machine = machines.lookup(location.host);
+
+ /* No such machine */
+ if (!machine || !machine.visible) {
+ machine = {
+ key: location.host,
+ address: location.host,
+ label: location.host,
+ state: "failed",
+ problem: "not-found",
+ };
+ }
+
+ const compiled = compile_manifests(machine.manifests);
+ if (machine.manifests && !location.path) {
+ // Find the default path based on the manifest.
+ const menu_items = compiled.ordered("menu");
+ if (menu_items.length > 0 && menu_items[0])
+ location.path = menu_items[0].path;
+ location.path = "system";
+ replace_window_location(location);
+ }
+
+ // Remember the most recent history for each host, and each
+ // host/path combinaton. This is used by JUMP to complete
+ // partial locations.
+ //
+ remember_location(location.host, location.path, location.hash);
+
+ const item = compiled.find_path_item(location.path);
+
+ self.current_location = location;
+ self.current_machine = machine;
+ self.current_machine_manifest_items = compiled;
+ self.current_manifest_item = item;
+
+ let frame = null;
+ if (location.path && (machine.state == "connected" || machine.state == "connecting"))
+ frame = ensure_frame(machine, location.path, location.hash, item.label);
+
+ if (frame != self.current_frame) {
+ const prev_frame = self.current_frame;
+ self.current_frame = frame;
+
+ if (prev_frame)
+ send_frame_hidden_hint(prev_frame.name);
+ if (frame)
+ send_frame_hidden_hint(frame.name);
+ }
+
+ // Remove all dead frames that are not the current one.
+ for (const n of Object.keys(frames)) {
+ if (frames[n].url == null && frames[n] != self.current_frame)
+ delete frames[n];
+ }
+
+ self.dispatchEvent("update");
+ }
+
+ self.update = update;
+
+ return self;
+}
diff --git a/pkg/shell/topnav.jsx b/pkg/shell/topnav.jsx
index 156ff62fa4d..c1ceff5c5fc 100644
--- a/pkg/shell/topnav.jsx
+++ b/pkg/shell/topnav.jsx
@@ -44,18 +44,7 @@ export class TopNav extends React.Component {
constructor(props) {
super(props);
- let hash = props.state.hash;
- let component = props.state.component;
-
- if (props.machine && props.compiled.compat && props.compiled.compat[component]) { // Old cockpit packages used to be in shell/shell.html
- hash = props.compiled.compat[component];
- component = "shell/shell";
- }
- const frame = component ? props.index.frames.lookup(props.machine, component, hash) : undefined;
-
this.state = {
- component,
- frame,
docsOpened: false,
menuOpened: false,
showActivePages: false,
@@ -83,26 +72,6 @@ export class TopNav extends React.Component {
window.removeEventListener("blur", this.handleClickOutside);
}
- static getDerivedStateFromProps(nextProps, prevState) {
- let hash = nextProps.state.hash;
- let component = nextProps.state.component;
-
- if (nextProps.machine && nextProps.compiled.compat && nextProps.compiled.compat[component]) { // Old cockpit packages used to be in shell/shell.html
- hash = nextProps.compiled.compat[component];
- component = "shell/shell";
- }
-
- if (component !== prevState.component) {
- const frame = component ? nextProps.index.frames.lookup(nextProps.machine, component, hash) : undefined;
- return {
- frame,
- component,
- };
- }
-
- return null;
- }
-
handleModeClick = (event, isSelected) => {
const theme = event.currentTarget.id;
this.setState({ theme });
@@ -115,34 +84,53 @@ export class TopNav extends React.Component {
window.dispatchEvent(styleEvent);
localStorage.setItem("shell:style", theme);
+ this.props.state.update();
};
render() {
const Dialogs = this.context;
- const connected = this.props.machine.state === "connected";
+ const {
+ current_location,
+ current_machine,
+ current_machine_manifest_items,
+ current_frame,
+ } = this.props.state;
+
+ const connected = current_machine.state === "connected";
let docs = [];
if (!this.superuser_connection || (this.superuser_connection.options.host !=
- this.props.machine.connection_string)) {
+ current_machine.connection_string)) {
if (this.superuser_connection) {
this.superuser_connection.close();
this.superuser_connection = null;
}
if (connected) {
- this.superuser_connection = cockpit.dbus(null, { bus: "internal", host: this.props.machine.connection_string });
+ this.superuser_connection = cockpit.dbus(null, { bus: "internal", host: current_machine.connection_string });
this.superuser = this.superuser_connection.proxy("cockpit.Superuser", "/superuser");
}
}
- const item = this.props.compiled.items[this.props.state.component];
+ // NOTE: The following is arguably wrong. We should not index
+ // the manifest items with the location path. Instead, we
+ // should use state.current_manifest_item.
+ //
+ // See https://github.com/cockpit-project/cockpit/issues/21040
+ //
+ const item = current_machine_manifest_items.items[current_location.path];
if (item && item.docs)
docs = item.docs;
- // Check for parent as well
+ // NOTE: The following is also arguably wrong. We should not
+ // index the manifests with the location path either. We
+ // should use only the first component after splitting the
+ // path at "/". Also, we should look into
+ // current_machine.manifests instead of cockpit.manifests.
+ //
if (docs.length === 0) {
- const comp = cockpit.manifests[this.props.state.component];
+ const comp = cockpit.manifests[current_location.path];
if (comp && comp.parent && comp.parent.docs)
docs = comp.parent.docs;
}
@@ -187,7 +175,7 @@ export class TopNav extends React.Component {
onClick={() => {
this.setState(prevState => { return { menuOpened: !prevState.menuOpened } });
}}>
-
+