diff --git a/README.md b/README.md index 4a5ae36..0bef81f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# cockpit-navigator -File Browser for Cockpit +# Cockpit Navigator +A File System Browser for Cockpit. diff --git a/makefile b/makefile index a557e73..9db3e15 100644 --- a/makefile +++ b/makefile @@ -1,3 +1,18 @@ +# Cockpit Navigator - A File System Browser for Cockpit. +# Copyright (C) 2021 Josh Boudreau + +# This file is part of Cockpit Navigator. +# Cockpit Navigator is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# Cockpit Navigator 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 General Public License for more details. +# You should have received a copy of the GNU General Public License +# along with Cockpit Navigator. If not, see . + default: diff --git a/navigator/branding/logo-dark.svg b/navigator/branding/logo-dark.svg new file mode 100644 index 0000000..52ff1d2 --- /dev/null +++ b/navigator/branding/logo-dark.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/navigator/branding/logo-light.svg b/navigator/branding/logo-light.svg new file mode 100644 index 0000000..ad5f8d7 --- /dev/null +++ b/navigator/branding/logo-light.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/navigator/navigator.css b/navigator/navigator.css index eaa9cd1..c20b04f 100644 --- a/navigator/navigator.css +++ b/navigator/navigator.css @@ -1,3 +1,21 @@ +/* + Cockpit Navigator - A File System Browser for Cockpit. + Copyright (C) 2021 Josh Boudreau + Copyright (C) 2021 Sam Silver + + This file is part of Cockpit Navigator. + Cockpit Navigator is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Cockpit Navigator 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 General Public License for more details. + You should have received a copy of the GNU General Public License + along with Cockpit Navigator. If not, see . + */ + :root { /* white style */ --container: #fff; @@ -7,6 +25,12 @@ --selected: #fff; --toggle-light: #151515; --toggle-dark: #ccc; + --scrollbar-thumb: var(--border); + --scrollbar-bg: var(--navigation); + --scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg); + --loading-bg-color: rgba(255, 255, 255, 0.5); + --textarea-bg: var(--navigation); + --logo-45: #333; } [data-theme="dark"] { @@ -15,9 +39,81 @@ --border: #3c3f42; --font: #fff; --selected: #191a1b; - --navigation: #121212; + --scrollbar-thumb: var(--container); + --scrollbar-bg: var(--navigation); + --scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg); + --loading-bg-color: rgba(33, 36, 39, 0.5); + --textarea-bg: var(--navigation); + --logo-45: #fff; } +.pf-c-button:disabled[data-theme="dark"] { + background-color: var(--border); +} + +.nav-loader-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: var(--loading-bg-color); + z-index: 10; +} + +.nav-loader-centerer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 27%; + display: flex; + align-items: center; + align-content: center; +} + +.nav-loader { + margin: auto; + border: 6px solid rgba(0,0,0,0); + border-radius: 50%; + border-top: 6px solid var(--border); + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; /* Safari */ + animation: spin 2s linear infinite; +} + +/* Safari */ +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +body::-webkit-scrollbar { + width: 11px; +} + +body { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-color); +} + +body::-webkit-scrollbar-track { + background: var(--scrollbar-bg); +} + +body::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb) ; + border-radius: 6px; + border: 3px solid var(--scrollbar-bg); +} + + .flex-row { display: flex; flex-direction: row; @@ -36,10 +132,18 @@ margin-right: 1em; } +.horizontal-spacer-sm { + margin-right: 0.25em; +} + .vertical-spacer { margin-bottom: 1em; } +.spacer-stretchy { + flex-grow: 1; +} + .nav-hidden { display: none; } @@ -49,10 +153,12 @@ background-color: var(--container); color: var(--font); padding: 1em; + padding-bottom: 0; } .navigation-bar { background-color: var(--container); + color: inherit; flex-grow: 1; padding: 0.25em 1em 0.25em 1em; border: 1px solid var(--border); @@ -166,18 +272,63 @@ align-items: flex-start; } +.editor-header { + font-weight: bold; +} + +.edit-file-contents { + height: 100%; + flex-basis: 0; + flex-grow: 8; + flex-flow: column nowrap; + align-items: stretch; +} + +.edit-file-contents > textarea { + flex-grow: 1; + white-space: pre; + overflow: auto; + resize: none; + border: 1px solid var(--border); + border-radius: 4px; + outline: none; + padding: 5px; + color: var(--font); + background-color: var(--textarea-bg); +} + +.nav-footer { + flex: 1; + align-items: baseline; + justify-content: space-between; + padding: 5px; +} + +.nav-footer > a > img { + height: 1.25em; + width: auto; + margin-bottom: 4px; +} + +.nav-footer > a > .logo-45 { + font-weight: 900; + color: var(--logo-45); +} + +.nav-footer > a > .logo-drives { + font-weight: 600; + color: #981c20; +} + .nav-toggle { - position: absolute; - right: 0; - bottom: 0.5em; - margin-right: 1.9em; + justify-self: flex-end; } .switch { position: relative; display: inline-block; - width: 38px; - height: 20px; + width: 30px; + height: 17px; } .switch input { @@ -201,8 +352,8 @@ .slider:before { position: absolute; content: ""; - height: 16px; - width: 16px; + height: 13px; + width: 13px; left: 2px; bottom: 2px; background-color: white; @@ -219,13 +370,13 @@ input:focus + .slider { } input:checked + .slider:before { - -webkit-transform: translateX(18px); - -ms-transform: translateX(18px); - transform: translateX(18px); + -webkit-transform: translateX(13px); + -ms-transform: translateX(13px); + transform: translateX(13px); } .slider.round { - border-radius: 34px; + border-radius: 17px; } .slider.round:before { diff --git a/navigator/navigator.html b/navigator/navigator.html index ea742f2..89c6cc2 100644 --- a/navigator/navigator.html +++ b/navigator/navigator.html @@ -1,20 +1,20 @@ - @@ -30,17 +30,28 @@ +
- + + + +
@@ -49,6 +60,17 @@
+
+ diff --git a/navigator/navigator.js b/navigator/navigator.js index a27bdbb..feac31b 100644 --- a/navigator/navigator.js +++ b/navigator/navigator.js @@ -1,13 +1,32 @@ -function property_entry_html(key, value) { +/* + Cockpit Navigator - A File System Browser for Cockpit. + Copyright (C) 2021 Josh Boudreau + Copyright (C) 2021 Sam Silver + + This file is part of Cockpit Navigator. + Cockpit Navigator is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Cockpit Navigator 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 General Public License for more details. + You should have received a copy of the GNU General Public License + along with Cockpit Navigator. If not, see . + */ + +function property_entry_html(/*string*/ key, /*string*/ value) { var html = '"; + html += '' + key + ''; + html += '' + value + ''; + html += ''; return html; } -function format_bytes(bytes) { - if (bytes === 0) return "0 B"; +function format_bytes(/*int*/ bytes) { + if (bytes === 0) + return "0 B"; var units = [" B", " KiB", " MiB", " GiB", " TiB", " PiB"]; var index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); var pow = Math.pow(1024, index); @@ -15,7 +34,7 @@ function format_bytes(bytes) { return formatted.toFixed(2).toString() + units[index]; } -function format_time(timestamp) { +function format_time(/*int*/ timestamp) { var date = new Date(timestamp * 1000); return date.toLocaleString(); } @@ -35,72 +54,71 @@ function format_permissions(/*int*/ mode) { return result; } -/* - * Code to change theme - */ - -const toggleSwitch = document.getElementById("toggle-theme"); - -function switchTheme(e) { - if (e.target.checked) { - document.documentElement.setAttribute("data-theme", "dark"); - } else { +function set_last_theme_state() { + var toggle_switch = document.getElementById("toggle-theme"); + var state = localStorage.getItem("houston-theme-state"); + var icon = document.getElementById("houston-theme-icon"); + var logo = document.getElementById("logo-45d"); + if (state === "light") { + toggle_switch.checked = false; document.documentElement.setAttribute("data-theme", "light"); - } -} - -toggleSwitch.addEventListener("change", switchTheme, false); - -/* cephfs_dir_stats - * Receives: path to folder - * Does: Tries command with --json flag at path to folder. If - * command fails and gives an error then catch that error - * Returns: JSON object or nothing - */ -async function cephfs_dir_stats(path) { - try { - var proc = await cockpit.spawn(["cephfs-dir-stats", "-j", path], { - err: "ignore", - }); - return JSON.parse(proc.replace(/\[|\]/g, "")); - } catch { - return []; - } -} - -/* in_json - * Receives: A boolean to see if key is in JSON object - * and the JSON objects value if it exists - * Does: Checks if JSON key exists; if so, return the - * value of the key, if not, return the string "N/A" - * Returns: The value of key or "N/A" - */ -function in_json(is_key, value) { - if (is_key) { - return value; + icon.classList.remove("fa-moon"); + icon.classList.add("fa-sun"); + logo.src = "branding/logo-light.svg"; + } else if (state === "dark") { + toggle_switch.checked = true; + document.documentElement.setAttribute("data-theme", "dark"); + icon.classList.remove("fa-sun"); + icon.classList.add("fa-moon"); + logo.src = "branding/logo-dark.svg"; } else { - return "N/A"; + toggle_switch.checked = false; + state = "light"; + localStorage.setItem("houston-theme-state", state); + logo.src = "branding/logo-light.svg"; } } +function switch_theme(/*event*/ e) { + var icon = document.getElementById("houston-theme-icon"); + var logo = document.getElementById("logo-45d"); + var state = ""; + if (e.target.checked) { + state = "dark"; + icon.classList.remove("fa-sun"); + icon.classList.add("fa-moon"); + logo.src = "branding/logo-dark.svg"; + } else { + state = "light"; + icon.classList.remove("fa-moon"); + icon.classList.add("fa-sun"); + logo.src = "branding/logo-light.svg"; + } + document.documentElement.setAttribute("data-theme", state); + localStorage.setItem("houston-theme-state", state); +} + class NavEntry { constructor(/*string or array*/ path, /*dict*/ stat, /*NavWindow*/ nav_window_ref) { this.nav_window_ref = nav_window_ref; - if (typeof path == "string") this.path = path.split("/").splice(1); - else this.path = path; + if (typeof path == "string") + this.path = path.split("/").splice(1); + else + this.path = (path.length) ? path : [""]; this.dom_element = document.createElement("div"); this.dom_element.classList.add("nav-item"); - let icon = (this.dom_element.nav_item_icon = document.createElement("i")); + let icon = this.dom_element.nav_item_icon = document.createElement("i"); icon.classList.add("nav-file-icon"); - let title = (this.dom_element.nav_item_title = document.createElement("div")); + let title = this.dom_element.nav_item_title = document.createElement("div"); title.classList.add("nav-item-title"); title.innerText = this.filename(); this.dom_element.appendChild(icon); this.dom_element.appendChild(title); this.stat = stat; this.dom_element.addEventListener("click", this); + this.is_hidden_file = this.filename().startsWith('.'); } - handleEvent(e) { + handleEvent(/*event*/ e) { switch (e.type) { case "click": this.show_properties(); @@ -113,16 +131,21 @@ class NavEntry { while (this.dom_element.firstChild) { this.dom_element.removeChild(this.dom_element.firstChild); } - if (this.dom_element.parentElement) this.dom_element.parentElement.removeChild(this.dom_element); + if (this.dom_element.parentElement) + this.dom_element.parentElement.removeChild(this.dom_element); } filename() { var name = this.path[this.path.length - 1]; - if (name === "") name = "/"; + if (name === "") + name = "/"; return name; } path_str() { return "/" + this.path.join("/"); } + parent_dir() { + return this.path.slice(0, this.path.length - 1); + } show() { document.getElementById("nav-contents-view").appendChild(this.dom_element); } @@ -133,20 +156,20 @@ class NavEntry { return this.stat["mode"] & 0o777; } async chmod(/*int*/ new_perms) { - var proc = cockpit.spawn(["chmod", (new_perms & 0o777).toString(8), this.path_str()], { - superuser: "try", - err: "out", - }); + var proc = cockpit.spawn( + ["chmod", (new_perms & 0o777).toString(8), this.path_str()], + {superuser: "try", err: "out"} + ); proc.fail((e, data) => { window.alert(data); }); await proc; } async chown(/*string*/ new_owner, /*string*/ new_group) { - var proc = cockpit.spawn(["chown", [new_owner, new_group].join(":"), this.path_str()], { - superuser: "try", - err: "out", - }); + var proc = cockpit.spawn( + ["chown", [new_owner, new_group].join(":"), this.path_str()], + {superuser: "try", err: "out"} + ); proc.fail((e, data) => { window.alert(data); }); @@ -155,7 +178,7 @@ class NavEntry { async mv(/*string*/ new_path) { var proc = cockpit.spawn( ["mv", "-n", this.path_str(), [this.nav_window_ref.pwd().path_str(), new_path].join("/")], - { superuser: "try", err: "out" } + {superuser: "try", err: "out"} ); proc.fail((e, data) => { window.alert(data); @@ -181,20 +204,14 @@ class NavEntry { populate_edit_fields() { document.getElementById("nav-edit-filename").value = this.filename(); var mode_bits = [ - "other-exec", - "other-write", - "other-read", - "group-exec", - "group-write", - "group-read", - "owner-exec", - "owner-write", - "owner-read", + "other-exec", "other-write", "other-read", + "group-exec", "group-write", "group-read", + "owner-exec", "owner-write", "owner-read" ]; for (let i = 0; i < mode_bits.length; i++) { var bit_check = 1 << i; var result = this.stat["mode"] & bit_check; - document.getElementById(mode_bits[i]).checked = result != 0; + document.getElementById(mode_bits[i]).checked = (result != 0); } document.getElementById("nav-edit-owner").value = this.stat["owner"]; document.getElementById("nav-edit-group").value = this.stat["group"]; @@ -206,36 +223,17 @@ class NavFile extends NavEntry { super(path, stat, nav_window_ref); this.nav_type = "file"; this.dom_element.nav_item_icon.classList.add("fas", "fa-file"); - } - handleEvent(e) { - super.handleEvent(e); - } - async rm() { - var proc = cockpit.spawn(["rm", "-f", this.path_str()], { superuser: "try", err: "out" }); - proc.fail((e, data) => { - window.alert(data); - }); - await proc; - } -} - -class NavDir extends NavEntry { - constructor(/*string or array*/ path, /*dict*/ stat, nav_window_ref) { - super(path, stat, nav_window_ref); // super = call parent - this.nav_type = "dir"; - this.dom_element.nav_item_icon.classList.add("fas", "fa-folder"); this.double_click = false; - this.ceph_stats = []; - cephfs_dir_stats(this.path_str()).then((data) => (this.ceph_stats = data)); } - handleEvent(e) { - switch (e.type) { + handleEvent(/*event*/ e) { + switch(e.type){ case "click": - if (this.double_click) this.nav_window_ref.cd(this); - else { - // single click + if(this.double_click) + this.show_edit_file_contents(); + else{ // single click this.double_click = true; - if (this.timeout) clearTimeout(this.timeout); + if(this.timeout) + clearTimeout(this.timeout) this.timeout = setTimeout(() => { this.double_click = false; }, 500); @@ -244,75 +242,171 @@ class NavDir extends NavEntry { } super.handleEvent(e); } - async get_children(nav_window_ref) { - var children = []; - var data = await cockpit.spawn(["/usr/share/cockpit/navigator/scripts/ls.py", this.path_str()], { - err: "ignore", - }); - var response = JSON.parse(data); - this.stat = response["."]["stat"]; - var entries = response["children"]; - entries.forEach((entry) => { - var filename = entry["filename"]; - var path = this.path.length >= 1 && this.path[0] ? [...this.path, filename] : [filename]; - var stat = entry["stat"]; - if (entry["isdir"]) children.push(new NavDir(path, stat, nav_window_ref)); - else children.push(new NavFile(path, stat, nav_window_ref)); - }); - children.sort((first, second) => { - if (first.nav_type === second.nav_type) { - return first.filename().localeCompare(second.filename()); - } - if (first.nav_type === "dir") return -1; - return 1; - }); - return children; - } async rm() { - var proc = cockpit.spawn(["rmdir", this.path_str()], { superuser: "try", err: "out" }); + var proc = cockpit.spawn( + ["rm", "-f", this.path_str()], + {superuser: "try", err: "out"} + ); proc.fail((e, data) => { window.alert(data); }); await proc; } - show_properties() { - var extra_properties = ""; + async show_edit_file_contents() { + for (let button of document.getElementsByTagName("button")) { + if (!button.classList.contains("editor-btn")) + button.disabled = true; + } + document.getElementById("pwd").disabled = true; + var proc_output = await cockpit.spawn(["file", "--mime-type", this.path_str()], {superuser: "try"}); + var fields = proc_output.split(':'); + var type = fields[1].trim(); + if(!(type.match(/^text/) || type.match(/^inode\/x-empty$/) || this.stat["size"] === 0)){ + if(!window.confirm("File is of type `" + type + "`. Are you sure you want to edit it?")) + return; + } + var contents = await cockpit.spawn(["cat", this.path_str()], {superuser: "try"}); + document.getElementById("nav-edit-contents-textarea").value = contents; + document.getElementById("nav-cancel-edit-contents-btn").onclick = this.hide_edit_file_contents.bind(this); + document.getElementById("nav-continue-edit-contents-btn").onclick = this.write_to_file.bind(this); + document.getElementById("nav-edit-contents-header").innerText = "Editing " + this.path_str(); + document.getElementById("nav-contents-view").style.display = "none"; + document.getElementById("nav-edit-contents-view").style.display = "flex"; + } + async write_to_file() { + var new_contents = document.getElementById("nav-edit-contents-textarea").value; + await cockpit.script("echo -n \"$1\" > $2", [new_contents, this.path_str()], {superuser: "try"}); + this.nav_window_ref.refresh(); + this.hide_edit_file_contents(); + } + hide_edit_file_contents() { + document.getElementById("nav-edit-contents-view").style.display = "none"; + document.getElementById("nav-contents-view").style.display = "flex"; + for (let button of document.getElementsByTagName("button")) { + button.disabled = false; + } + document.getElementById("pwd").disabled = false; + } +} +class NavDir extends NavEntry { + constructor(/*string or array*/ path, /*dict*/ stat, nav_window_ref) { + super(path, stat, nav_window_ref); + this.nav_type = "dir"; + this.dom_element.nav_item_icon.classList.add("fas", "fa-folder"); + this.double_click = false; + } + handleEvent(/*event*/ e) { + switch (e.type) { + case "click": + if (this.double_click) + this.nav_window_ref.cd(this); + else { + // single click + this.double_click = true; + if (this.timeout) + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.double_click = false; + }, 500); + } + break; + } + super.handleEvent(e); + } + async get_children(/*NavWindow*/ nav_window_ref, /*boolean*/ no_alert = false) { + var children = []; + var proc = cockpit.spawn( + ["/usr/share/cockpit/navigator/scripts/ls.py", this.path_str()], + {err:"out", superuser: "try"} + ); + proc.fail((e, data) => { + if(!no_alert) + window.alert(data); + }); + var data = await proc; + var response = JSON.parse(data); + this.stat = response["."]["stat"]; + var entries = response["children"]; + entries.forEach((entry) => { + var filename = entry["filename"]; + var path = (this.path.length >= 1 && this.path[0]) ? [...this.path, filename] : [filename]; + var stat = entry["stat"]; + if (entry["isdir"]) + children.push(new NavDir(path, stat, nav_window_ref)); + else + children.push(new NavFile(path, stat, nav_window_ref)); + }); + children.sort((first, second) => { + if (first.nav_type === second.nav_type) { + return first.filename().localeCompare(second.filename()); + } + if (first.nav_type === "dir") + return -1; + return 1; + }); + return children; + } + async rm() { + var proc = cockpit.spawn( + ["rmdir", this.path_str()], + {superuser: "try", err: "out"} + ); + proc.fail((e, data) => { + window.alert(data); + }); + await proc; + } + async cephfs_dir_stats() { + try { + var proc = await cockpit.spawn( + ["cephfs-dir-stats", "-j", this.path_str()], + {err: "ignore"} + ); + return JSON.parse(proc)[0]; + } catch { + return null; + } + } + async show_properties() { + var extra_properties = ""; + if(!this.hasOwnProperty("ceph_stats")) + this.ceph_stats = await this.cephfs_dir_stats(); // See if a JSON object exists for folder we are currently looking at - if (this.ceph_stats.length !== 0) { + if (this.ceph_stats !== null) { extra_properties += '
'; extra_properties += property_entry_html( "Files", - in_json(this.ceph_stats.hasOwnProperty("files"), this.ceph_stats.files) + this.ceph_stats.hasOwnProperty("files") ? this.ceph_stats.files : "N/A" ); extra_properties += property_entry_html( "Directories", - in_json(this.ceph_stats.hasOwnProperty("subdirs"), this.ceph_stats.subdirs) + this.ceph_stats.hasOwnProperty("subdirs") ? this.ceph_stats.subdirs : "N/A" ); extra_properties += property_entry_html( "Recursive files", - in_json(this.ceph_stats.hasOwnProperty("rfiles"), this.ceph_stats.rfiles) + this.ceph_stats.hasOwnProperty("rfiles") ? this.ceph_stats.rfiles : "N/A" ); extra_properties += property_entry_html( "Recursive directories", - in_json(this.ceph_stats.hasOwnProperty("rsubdirs"), this.ceph_stats.rsubdirs) + this.ceph_stats.hasOwnProperty("rsubdirs") ? this.ceph_stats.rsubdirs : "N/A" ); extra_properties += property_entry_html( "Total size", - in_json(this.ceph_stats.hasOwnProperty("rbytes"), this.ceph_stats.rbytes) + this.ceph_stats.hasOwnProperty("rbytes") ? this.ceph_stats.rbytes : "N/A" ); extra_properties += property_entry_html( "Layout pool", - in_json(this.ceph_stats.hasOwnProperty("layout.pool"), this.ceph_stats["layout.pool"]) + this.ceph_stats.hasOwnProperty("layout.pool") ? this.ceph_stats["layout.pool"] : "N/A" ); extra_properties += property_entry_html( "Max files", - in_json(this.ceph_stats.hasOwnProperty("max_files"), this.ceph_stats.max_files) + this.ceph_stats.hasOwnProperty("max_files") ? this.ceph_stats.max_files : "N/A" ); extra_properties += property_entry_html( "Max bytes", - in_json(this.ceph_stats.hasOwnProperty("max_bytes"), this.ceph_stats.max_bytes) + this.ceph_stats.hasOwnProperty("max_bytes") ? this.ceph_stats.max_bytes : "N/A" ); } super.show_properties(extra_properties); @@ -322,6 +416,7 @@ class NavDir extends NavEntry { class NavWindow { constructor() { this.path_stack = [new NavDir("/", this)]; + this.path_stack_index = this.path_stack.length - 1; this.selected_entry = this.pwd(); this.entries = []; this.window = document.getElementById("nav-contents-view"); @@ -336,34 +431,54 @@ class NavWindow { } } async refresh() { + var num_dirs = 0; + var num_files = 0; + var show_hidden = document.getElementById("nav-show-hidden").checked; + this.start_load(); var files = await this.pwd().get_children(this); while (this.entries.length) { var entry = this.entries.pop(); entry.destroy(); } files.forEach((file) => { - file.show(); + if (file.nav_type === "dir") + num_dirs++; + else + num_files++; + if(!file.is_hidden_file || show_hidden) + file.show(); this.entries.push(file); }); - document.getElementById("pwd").innerText = this.pwd().path_str(); + document.getElementById("pwd").value = this.pwd().path_str(); this.set_selected(this.pwd()); this.show_selected_properties(); + document.getElementById("nav-num-dirs").innerText = num_dirs.toString(); + document.getElementById("nav-num-files").innerText = num_files.toString(); + this.stop_load(); } pwd() { - return this.path_stack[this.path_stack.length - 1]; + return this.path_stack[this.path_stack_index]; } - cd(new_dir) { + cd(/*NavDir*/ new_dir) { + this.path_stack.length = this.path_stack_index + 1; this.path_stack.push(new_dir); + this.path_stack_index = this.path_stack.length - 1; this.refresh().catch(() => { - this.path_stack.pop(); - this.refresh(); - window.alert(new_dir.path_str() + " is inaccessible."); + this.back(); }); } - up() { - if (this.path_stack.length > 1) this.path_stack.pop(); + back() { + this.path_stack_index = Math.max(this.path_stack_index - 1, 0); this.refresh(); } + forward() { + this.path_stack_index = Math.min(this.path_stack_index + 1, this.path_stack.length - 1); + this.refresh(); + } + up() { + if(this.pwd().path_str() !== '/') + this.cd(new NavDir(this.pwd().parent_dir())); + } show_selected_properties() { this.selected_entry.show_properties(); } @@ -401,8 +516,8 @@ class NavWindow { if ( !window.confirm( "Warning: editing `" + - this.selected_entry.path_str() + - "` can be dangerous. Are you sure?" + this.selected_entry.path_str() + + "` can be dangerous. Are you sure?" ) ) { return; @@ -424,7 +539,8 @@ class NavWindow { var bit = 0; for (let category of category_list) { for (let action of action_list) { - if (document.getElementById(category + "-" + action).checked) result |= 1 << bit; + if (document.getElementById(category + "-" + action).checked) + result |= 1 << bit; bit++; } } @@ -470,15 +586,16 @@ class NavWindow { } async mkdir() { var new_dir_name = window.prompt("Directory Name: "); - if (new_dir_name === null) return; + if (new_dir_name === null) + return; if (new_dir_name.includes("/")) { window.alert("Directory name can't contain `/`."); return; } - var proc = cockpit.spawn(["mkdir", this.pwd().path_str() + "/" + new_dir_name], { - superuser: "try", - err: "out", - }); + var proc = cockpit.spawn( + ["mkdir", this.pwd().path_str() + "/" + new_dir_name], + {superuser: "try", err: "out"} + ); proc.fail((e, data) => { window.alert(data); }); @@ -487,49 +604,113 @@ class NavWindow { } async touch() { var new_file_name = window.prompt("File Name: "); - if (new_file_name === null) return; + if (new_file_name === null) + return; if (new_file_name.includes("/")) { window.alert("File name can't contain `/`."); return; } - var proc = cockpit.spawn(["touch", this.pwd().path_str() + "/" + new_file_name], { - superuser: "try", - err: "out", - }); + var proc = cockpit.spawn( + ["touch", this.pwd().path_str() + "/" + new_file_name], + {superuser: "try", err: "out"} + ); proc.fail((e, data) => { window.alert(data); }); await proc; this.refresh(); } + nav_bar_event_handler(/*event*/ e) { + switch(e.key){ + case 'Enter': + this.nav_bar_cd(); + break; + default: + break; + } + } + nav_bar_cd() { + var new_path = document.getElementById("pwd").value; + this.cd(new NavDir(new_path)); + } + async nav_bar_update_choices() { + var list = document.getElementById("possible-paths"); + var partial_path_str = document.getElementById("pwd").value; + var last_delim = partial_path_str.lastIndexOf('/'); + if(last_delim === -1) + return; + var parent_path_str = partial_path_str.slice(0, last_delim); + if(this.nav_bar_last_parent_path_str === parent_path_str) + return; + this.nav_bar_last_parent_path_str = parent_path_str; + var parent_dir = new NavDir(parent_path_str); + console.log(parent_dir.path_str()); + var error = false; + var objs = await parent_dir.get_children(this, true).catch(() => {error = true}); + if(error) + return; + objs = objs.filter((child) => {return child.nav_type === "dir"}); + while(list.firstChild) + list.removeChild(list.firstChild); + objs.forEach((obj) => { + var option = document.createElement("option"); + option.value = obj.path_str(); + list.appendChild(option); + }); + } + start_load() { + document.getElementById("nav-loader-container").hidden = false; + var buttons = document.getElementsByTagName("button"); + for (let button of buttons) { + button.disabled = true; + } + } + stop_load() { + document.getElementById("nav-loader-container").hidden = true; + var buttons = document.getElementsByTagName("button"); + for (let button of buttons) { + button.disabled = false; + } + } + toggle_show_hidden(e) { + var icon = document.getElementById("nav-show-hidden-icon"); + if (e.target.checked) { + icon.classList.remove("fa-low-vision"); + icon.classList.add("fa-eye"); + } else { + icon.classList.remove("fa-eye"); + icon.classList.add("fa-low-vision"); + } + this.refresh(); + } } let nav_window = new NavWindow(); function set_up_buttons() { + document.getElementById("nav-back-btn").addEventListener("click", nav_window.back.bind(nav_window)); + document.getElementById("nav-forward-btn").addEventListener("click", nav_window.forward.bind(nav_window)); document.getElementById("nav-up-dir-btn").addEventListener("click", nav_window.up.bind(nav_window)); document.getElementById("nav-refresh-btn").addEventListener("click", nav_window.refresh.bind(nav_window)); document.getElementById("nav-mkdir-btn").addEventListener("click", nav_window.mkdir.bind(nav_window)); document.getElementById("nav-touch-btn").addEventListener("click", nav_window.touch.bind(nav_window)); - document - .getElementById("nav-delete-btn") - .addEventListener("click", nav_window.delete_selected.bind(nav_window)); - document - .getElementById("nav-edit-properties-btn") - .addEventListener("click", nav_window.show_edit_selected.bind(nav_window)); - document - .getElementById("nav-cancel-edit-btn") - .addEventListener("click", nav_window.hide_edit_selected.bind(nav_window)); - document - .getElementById("nav-apply-edit-btn") - .addEventListener("click", nav_window.apply_edit_selected.bind(nav_window)); + document.getElementById("nav-delete-btn").addEventListener("click", nav_window.delete_selected.bind(nav_window)); + document.getElementById("nav-edit-properties-btn").addEventListener("click", nav_window.show_edit_selected.bind(nav_window)); + document.getElementById("nav-cancel-edit-btn").addEventListener("click", nav_window.hide_edit_selected.bind(nav_window)); + document.getElementById("nav-apply-edit-btn").addEventListener("click", nav_window.apply_edit_selected.bind(nav_window)); var mode_checkboxes = document.getElementsByClassName("mode-checkbox"); for (let checkbox of mode_checkboxes) { checkbox.addEventListener("change", nav_window.update_permissions_preview.bind(nav_window)); } + document.getElementById("pwd").addEventListener("input", nav_window.nav_bar_update_choices.bind(nav_window), false); + document.getElementById("pwd").addEventListener("focus", nav_window.nav_bar_update_choices.bind(nav_window), false); + document.getElementById("pwd").addEventListener("keydown", nav_window.nav_bar_event_handler.bind(nav_window)); + document.getElementById("toggle-theme").addEventListener("change", switch_theme, false); + document.getElementById("nav-show-hidden").addEventListener("change", nav_window.toggle_show_hidden.bind(nav_window)); } async function main() { + set_last_theme_state(); await nav_window.refresh(); set_up_buttons(); } diff --git a/navigator/scripts/cephfs-dir-stats b/navigator/scripts/cephfs-dir-stats new file mode 100755 index 0000000..2ad3dfe --- /dev/null +++ b/navigator/scripts/cephfs-dir-stats @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import sys +import re +import subprocess +import math +import json +from optparse import OptionParser + +############################################################################### +# Name: dir_attributes +# Args: path to directory, command to run +# Desc: executes getfattr to retrive ceph dir attribute and returns as dictionary +############################################################################### + + +def dir_attributes(path, type, command): + attrs = {} + try: + child = subprocess.Popen( + ["getfattr", "-n", "ceph." + type + "." + command, path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + output_string, err = child.communicate() + except OSError: + print("Error executing getfattr. Is xattr installed?") + sys.exit(1) + if child.wait() != 0: + if err.find("No such attribute") != -1: + return {} + else: + print("Error executing getfattr. Is xattr installed?") + sys.exit(1) + # return {} + fields = re.findall( + r"^ceph\.[dir|quota]+\.([^=]+)=\"([^\"]+)\"$", output_string, re.MULTILINE) + if len(fields) == 0: + print(f'No ceph xattrs, is {path} in a ceph filesystem?') + sys.exit(1) + attrs[fields[0][0]] = fields[0][1] + return attrs + + +############################################################################### +# Name: run_dir_commands +# Args: path to directory +# Desc: calls dir_attributes and quota_attrivutes multiple times each with a different command +# Retu: Returns the outputs of all commands in a dictionary +############################################################################### + + +def run_dir_commands(path): + outputs = {'path':path} + dirList = ["entries", "files", "rbytes", "rentries", + "rfiles", "rsubdirs", "subdirs", "layout.pool"] + quotaList = ["max_files", "max_bytes"] + for items in dirList: + outputs.update(dir_attributes(path, "dir", items)) + for items in quotaList: + outputs.update(dir_attributes(path, "quota", items)) + + if "rbytes" in outputs.keys(): + outputs["rbytes"] = format_bytes(int(outputs["rbytes"])) + if "max_bytes" in outputs.keys(): + outputs["max_bytes"] = format_bytes(int(outputs["max_bytes"])) + + return outputs + +############################################################################### +# Name: display_attributes +# Args: path to directory +# Desc: calls run_dir_commands and prints output +############################################################################### + + +def display_attributes(path): + attrs = run_dir_commands(path) + + print(path, ":") + max_width = len( + max(attrs.values(), key=lambda x: len(x.split(" ")[0])).split(" ")[0]) + print("Files: ", "{0:>{1}}".format( + attrs["files"], max_width) if "files" in attrs.keys() else "N/A") + print("Directories: ", "{0:>{1}}".format( + attrs["subdirs"], max_width) if "subdirs" in attrs.keys() else "N/A") + print("Recursive Files: ", "{0:>{1}}".format( + attrs["rfiles"], max_width) if "rfiles" in attrs.keys() else "N/A") + print("Recursive Directories: ", "{0:>{1}}".format( + attrs["rsubdirs"], max_width) if "rsubdirs" in attrs.keys() else "N/A") + print("Total Size: ", "{0:>{1}}".format(attrs["rbytes"].split(" ")[ + 0], max_width) + " " + attrs["rbytes"].split(" ")[1] if "rbytes" in attrs.keys() else "N/A") + print("Layout Pool: ", "{0:>{1}}".format( + attrs["layout.pool"], max_width) if "layout.pool" in attrs.keys() else "N/A") + print("Max Files: ", "{0:>{1}}".format( + attrs["max_files"], max_width) if "max_files" in attrs.keys() else " N/A") + print("Max Bytes: ", "{0:>{1}}".format( + attrs["max_bytes"], max_width) if "max_bytes" in attrs.keys() else " N/A") + print() + +################################################################################ +# Name: format_bytes +# Args: integer value in bytes +# Desc: formats size_bytes in SI base units and returns as string +################################################################################ + + +def format_bytes(size_bytes): + if size_bytes == 0: + return "0 B" + size_name = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB") + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return "%s %s" % (s, size_name[i]) + +############################################################################### +# Name: main (cephfs-dir-stats) +# Args: (see parser) +# Desc: lists recursive ceph stats of specified directory +############################################################################### + + +def main(): + parser = OptionParser() + parser.add_option("-j", "--json", help="output stats in JSON format.", action="store_true", dest="json", default=False) + (options, args) = parser.parse_args() + if len(args) == 0: + args = ["."] + if(options.json): + obj = [] + for arg in args: + obj.append(run_dir_commands(arg)) + obj = json.dumps(obj) + print(obj) + else: + for arg in args: + display_attributes(arg) + + +if __name__ == "__main__": + main() diff --git a/navigator/scripts/ls.py b/navigator/scripts/ls.py index 5be3592..4c49b56 100755 --- a/navigator/scripts/ls.py +++ b/navigator/scripts/ls.py @@ -1,5 +1,22 @@ #!/usr/bin/env python3 +""" + Cockpit Navigator - A File System Browser for Cockpit. + Copyright (C) 2021 Josh Boudreau + + This file is part of Cockpit Navigator. + Cockpit Navigator is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + Cockpit Navigator 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 General Public License for more details. + You should have received a copy of the GNU General Public License + along with Cockpit Navigator. If not, see . +""" + import os from stat import S_ISDIR, filemode import json @@ -14,6 +31,16 @@ def get_stat(full_path, filename = '/'): isdir = S_ISDIR(os.stat(full_path).st_mode) except OSError: pass + owner = '?' + try: + owner = getpwuid(stats.st_uid).pw_name + except: + pass + group = '?' + try: + group = getgrgid(stats.st_gid).gr_name + except: + pass response = { "filename": filename, "isdir": isdir, @@ -21,9 +48,9 @@ def get_stat(full_path, filename = '/'): "mode": stats.st_mode, "mode-str": filemode(stats.st_mode), "uid": stats.st_uid, - "owner": getpwuid(stats.st_uid).pw_name, + "owner": owner, "gid": stats.st_gid, - "group": getgrgid(stats.st_gid).gr_name, + "group": group, "size": stats.st_size, "atime": stats.st_atime, "mtime": stats.st_mtime,