Line data Source code
1 : /*
2 : * Copyright (C) 2024 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 338 : import cockpit from "cockpit";
7 :
8 338 : import React, { useEffect } from 'react';
9 338 : import { createRoot } from "react-dom/client";
10 :
11 : import {
12 : Modal, ModalBody, ModalFooter, ModalHeader
13 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
14 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
15 :
16 : import { WithDialogs } from "dialogs.jsx";
17 : import { useInit, useEvent, useOn, useLoggedInUser } from "hooks";
18 :
19 : import { TopNav } from "./topnav.jsx";
20 : import { SidebarToggle, PageNav } from "./nav.jsx";
21 : import { CockpitHosts, CockpitCurrentHost } from "./hosts.jsx";
22 : import { HostModalState, HostModal, connect_host } from "./hosts_dialog.jsx";
23 : import { Frames } from "./frames.jsx";
24 : import { EarlyFailure, Disconnected, MachineTroubleshoot } from "./failures.jsx";
25 :
26 : import { ShellState } from "./state.jsx";
27 : import { get_session_controller } from "./session";
28 :
29 : import { SkipToContent } from '@patternfly/react-core/dist/esm/components/SkipToContent/index.js';
30 :
31 : import 'cockpit-dark-theme'; // once per page
32 :
33 : import '../lib/patternfly/patternfly-6-cockpit.scss';
34 : import "./shell.scss";
35 :
36 338 : const _ = cockpit.gettext;
37 :
38 : interface SkipLinkProps {
39 : focus_id: string;
40 : children?: React.ReactNode
41 : }
42 :
43 : // We create a wrapper specifically to handle edge-cases like headless browsers
44 : // not getting focus when clicking an anchor to a heading. We can also scroll
45 : // it into view in case it wouldn't do that before - just to be safe.
46 335 : const SkipLink = ({ focus_id, children }: SkipLinkProps) => {
47 335 : return (
48 335 : <SkipToContent
49 335 : href={`#${focus_id}`}
50 0 : onClick={ev => {
51 0 : const el = document.getElementById(focus_id);
52 0 : el?.focus();
53 0 : el?.scrollIntoView();
54 0 : ev.preventDefault();
55 0 : }}>
56 335 : {children}
57 335 : </SkipToContent>
58 : );
59 335 : };
60 :
61 335 : const SessionCountdownModal = () => {
62 335 : const controller = get_session_controller();
63 335 : useOn(controller, "changed");
64 :
65 335 : controller.inhibit_activity_reporting(controller.countdown > 0);
66 :
67 335 : if (controller.countdown <= 0)
68 335 : return null;
69 :
70 61 : return (
71 61 : <Modal isOpen position="top" variant="medium"
72 61 : id="session-timeout-modal">
73 61 : <ModalHeader title={_("Session is about to expire")} />
74 61 : <ModalBody>
75 61 : { cockpit.format(_("You will be logged out in $0 seconds."), controller.countdown) }
76 61 : </ModalBody>
77 61 : <ModalFooter>
78 61 : <Button variant='primary'
79 0 : onClick={() => controller.continue_session()}
80 : >
81 61 : {_("Continue session")}
82 61 : </Button>
83 61 : </ModalFooter>
84 61 : </Modal>
85 : );
86 335 : };
87 :
88 338 : const Shell = () => {
89 335 : const current_user = useLoggedInUser()?.name || "";
90 338 : const state = useInit(() => new ShellState());
91 338 : const session_controller = get_session_controller();
92 338 : const host_modal_state = useInit(() => HostModalState());
93 :
94 338 : useOn(state, "update");
95 338 : useOn(session_controller, "changed");
96 338 : useEvent(host_modal_state, "changed");
97 :
98 338 : useEffect(() => {
99 8 : return state.on("connect", () => {
100 8 : connect_host(host_modal_state, state, state.current_machine);
101 8 : });
102 338 : }, [host_modal_state, state]);
103 :
104 338 : const {
105 338 : ready,
106 338 : config,
107 :
108 338 : current_machine,
109 338 : current_manifest_item,
110 338 : } = state;
111 :
112 338 : const { countdown, problem } = session_controller;
113 :
114 67 : if (problem && !ready)
115 64 : return <EarlyFailure />;
116 :
117 338 : if (!ready)
118 338 : return null;
119 :
120 335 : cockpit.assert(current_machine && current_manifest_item);
121 :
122 338 : const title_parts = [];
123 338 : if (current_manifest_item.label)
124 335 : title_parts.push(current_manifest_item.label);
125 335 : title_parts.push((current_machine.user || current_user) + "@" + current_machine.label);
126 338 : document.title = title_parts.join(" - ");
127 :
128 338 : if (countdown > 0)
129 61 : document.title = "(" + countdown + ") " + document.title;
130 :
131 335 : document.documentElement.lang = config.language;
132 335 : if (config.language_direction)
133 335 : document.documentElement.dir = config.language_direction;
134 :
135 335 : let failure = null;
136 64 : if (problem) {
137 64 : failure = <Disconnected problem={problem} />;
138 61 : } else if (current_machine.state != "connected") {
139 71 : failure = <MachineTroubleshoot machine={current_machine}
140 0 : onClick={() => connect_host(host_modal_state, state, current_machine)} />;
141 71 : }
142 :
143 335 : return (
144 335 : <div id="main" className="page"
145 335 : style={
146 335 : {
147 71 : '--ct-color-host-accent': (current_machine.address == "localhost" ? undefined : current_machine.color)
148 338 : } as React.CSSProperties
149 : }>
150 :
151 338 : <SkipLink focus_id="content">{_("Skip to content")}</SkipLink>
152 338 : <SkipLink focus_id="hosts-sel">{_("Skip main navigation")}</SkipLink>
153 :
154 338 : <div id="sidebar-toggle" className="pf-v6-c-select pf-m-dark sidebar-toggle">
155 338 : <SidebarToggle />
156 338 : </div>
157 :
158 338 : <div id="nav-system" className="area-ct-subnav nav-system-menu sidebar interact">
159 338 : <nav id="host-apps" className="host-apps">
160 338 : <PageNav state={state} />
161 338 : </nav>
162 338 : </div>
163 :
164 338 : <nav id="hosts-sel" className="navbar navbar-default navbar-pf navbar-pf-vertical" tabIndex={-1}>
165 338 : { config.host_switcher_enabled
166 74 : ? <CockpitHosts state={state} host_modal_state={host_modal_state} selector="nav-hosts" />
167 322 : : <CockpitCurrentHost current_user={current_user} machine={current_machine} />
168 : }
169 338 : </nav>
170 :
171 338 : <div id="nav-hosts" className="area-ct-subnav nav-hosts-menu sidebar" />
172 :
173 338 : <div id="topnav" className="header">
174 338 : <TopNav state={state} />
175 338 : </div>
176 :
177 338 : <Frames hidden={!!failure} state={state} />
178 :
179 338 : { failure &&
180 74 : <div id="failure-content" className="area-ct-content" role="main" tabIndex={-1}>
181 74 : { failure }
182 74 : </div>
183 : }
184 :
185 338 : <SessionCountdownModal />
186 338 : <HostModal state={host_modal_state} machines={state.machines} />
187 :
188 338 : </div>);
189 338 : };
190 :
191 : interface ShellWindow extends Window {
192 : features?: object;
193 : }
194 :
195 338 : function init() {
196 338 : cockpit.translate();
197 :
198 : /* Give us a name. This used to (maybe) indicate at some point to
199 : * child frames that we are a cockpit1 router frame. But they
200 : * actually check for a "cockpit1:" prefix of their own name.
201 : */
202 338 : window.name = "cockpit1";
203 :
204 : /* Tell the pages about our features. */
205 338 : (window as ShellWindow).features = {
206 338 : navbar_is_for_current_machine: true
207 338 : };
208 :
209 0 : function follow(arg: unknown) {
210 : /* A promise of some sort */
211 0 : if (arguments.length == 1 && arg && typeof arg == "object" && "then" in arg && typeof arg.then == "function") {
212 0 : arg.then(function(...args: unknown[]) { console.log(...args) },
213 0 : function(...args: unknown[]) { console.error(...args) });
214 0 : if ("stream" in arg && typeof arg.stream == "function")
215 0 : arg.stream(function(...args: unknown[]) { console.log(...args) });
216 0 : }
217 0 : }
218 :
219 338 : let zz_value: unknown;
220 :
221 : /* For debugging in the browser console */
222 338 : Object.defineProperties(window, {
223 338 : cockpit: { value: cockpit },
224 338 : zz: {
225 0 : get: function() { return zz_value },
226 0 : set: function(val) { zz_value = val; follow(val) }
227 338 : }
228 338 : });
229 :
230 338 : const root = createRoot(document.getElementById("shell")!);
231 338 : root.render(<WithDialogs><Shell /></WithDialogs>);
232 338 : }
233 :
234 338 : document.addEventListener("DOMContentLoaded", init);
|