From febc398cafc9f3901a7f001759323eb13e23f8fe Mon Sep 17 00:00:00 2001 From: Fons van der Plas Date: Tue, 13 Dec 2022 22:04:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=BA=20Process=20status=20(#2399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/common/clock sync.js | 47 +++ frontend/components/BottomRightPanel.js | 95 ++++-- frontend/components/CellInput.js | 3 +- .../CellInput/pluto_autocomplete.js | 3 +- frontend/components/DiscreteProgressBar.js | 55 ++++ frontend/components/Editor.js | 12 + frontend/components/ProcessTab.js | 255 ++++++++++++++++ frontend/components/RunArea.js | 2 +- frontend/components/welcome/Welcome.js | 1 + frontend/dark_color.css | 5 + frontend/editor.css | 271 +++++++++++++++++- frontend/editor.js | 1 + frontend/light_color.css | 227 +++++---------- src/Pluto.jl | 1 + src/evaluation/Run.jl | 17 +- src/evaluation/WorkspaceManager.jl | 48 +++- src/notebook/Notebook.jl | 11 +- src/notebook/saving and loading.jl | 6 +- src/packages/Packages.jl | 42 ++- src/webserver/Dynamic.jl | 6 + src/webserver/SessionActions.jl | 17 +- src/webserver/Status.jl | 115 ++++++++ 22 files changed, 1050 insertions(+), 190 deletions(-) create mode 100644 frontend/common/clock sync.js create mode 100644 frontend/components/DiscreteProgressBar.js create mode 100644 frontend/components/ProcessTab.js create mode 100644 src/webserver/Status.jl diff --git a/frontend/common/clock sync.js b/frontend/common/clock sync.js new file mode 100644 index 0000000000..dd6bbdfa40 --- /dev/null +++ b/frontend/common/clock sync.js @@ -0,0 +1,47 @@ +import { useContext, useState, useEffect } from "../imports/Preact.js" +import { PlutoActionsContext } from "./PlutoContext.js" + +/** Request the current time from the server, compare with the local time, and return the current best estimate of our time difference. Updates regularly. + * @param {{connected: boolean}} props + */ +export const useMyClockIsAheadBy = ({ connected }) => { + let pluto_actions = useContext(PlutoActionsContext) + + const [my_clock_is_ahead_by, set_my_clock_is_ahead_by] = useState(0) + + useEffect(() => { + console.error({ connected }) + if (connected) { + let f = async () => { + let getserver = () => pluto_actions.send("current_time").then((m) => m.message.time) + let getlocal = () => Date.now() / 1000 + + // once to precompile and to make sure that the server is not busy with other tasks + // console.log("getting server time warmup") + for (let i = 0; i < 16; i++) await getserver() + // console.log("getting server time warmup done") + + let t1 = await getlocal() + let s1 = await getserver() + let s2 = await getserver() + let t2 = await getlocal() + // console.log("getting server time done") + + let mytime = (t1 + t2) / 2 + let servertime = (s1 + s2) / 2 + + let diff = mytime - servertime + // console.info("My clock is ahead by ", diff, " s") + if (!isNaN(diff)) set_my_clock_is_ahead_by(diff) + } + + f() + + let handle = setInterval(f, 60 * 1000) + + return () => clearInterval(handle) + } + }, [connected]) + + return my_clock_is_ahead_by +} diff --git a/frontend/components/BottomRightPanel.js b/frontend/components/BottomRightPanel.js index 3680762ba9..aba8793cbe 100644 --- a/frontend/components/BottomRightPanel.js +++ b/frontend/components/BottomRightPanel.js @@ -1,7 +1,9 @@ -import { html, useState, useRef, useEffect } from "../imports/Preact.js" +import { html, useState, useRef, useEffect, useMemo } from "../imports/Preact.js" import { cl } from "../common/ClassTable.js" import { LiveDocsTab } from "./LiveDocsTab.js" +import { is_finished, ProcessTab, total_done, total_tasks } from "./ProcessTab.js" +import { useMyClockIsAheadBy } from "../common/clock sync.js" export const ENABLE_PROCESS_TAB = window.localStorage.getItem("ENABLE_PROCESS_TAB") === "true" @@ -20,27 +22,63 @@ window.PLUTO_TOGGLE_PROCESS_TAB = () => { window.location.reload() } -export let BottomRightPanel = ({ desired_doc_query, on_update_doc_query, notebook }) => { +/** + * @typedef PanelTabName + * @type {"docs" | "process" | null} + */ + +export const open_bottom_right_panel = (/** @type {PanelTabName} */ tab) => window.dispatchEvent(new CustomEvent("open_bottom_right_panel", { detail: tab })) + +/** + * @param {{ + * notebook: import("./Editor.js").NotebookData, + * desired_doc_query: string?, + * on_update_doc_query: (query: string?) => void, + * connected: boolean, + * }} props + */ +export let BottomRightPanel = ({ desired_doc_query, on_update_doc_query, notebook, connected }) => { let container_ref = useRef() const focus_docs_on_open_ref = useRef(false) - const [open_tab, set_open_tab] = useState(/** @type { "docs" | "process" | null} */ (null)) + const [open_tab, set_open_tab] = useState(/** @type { PanelTabName} */ (null)) const hidden = open_tab == null - // Open docs when "open_live_docs" event is triggered + // Open panel when "open_bottom_right_panel" event is triggered useEffect(() => { - let handler = () => { + let handler = (/** @type {CustomEvent} */ e) => { + console.log(e.detail) // https://github.com/fonsp/Pluto.jl/issues/321 focus_docs_on_open_ref.current = false - set_open_tab("docs") + set_open_tab(e.detail) if (window.getComputedStyle(container_ref.current).display === "none") { alert("This browser window is too small to show docs.\n\nMake the window bigger, or try zooming out.") } } - window.addEventListener("open_live_docs", handler) - return () => window.removeEventListener("open_live_docs", handler) + window.addEventListener("open_bottom_right_panel", handler) + return () => window.removeEventListener("open_bottom_right_panel", handler) }, []) + const [status_total, status_done] = useMemo( + () => + !ENABLE_PROCESS_TAB || notebook.status_tree == null + ? [0, 0] + : [ + // total_tasks minus 1, to exclude the notebook task itself + total_tasks(notebook.status_tree) - 1, + // the notebook task should never be done, but lets be sure and subtract 1 if it is: + total_done(notebook.status_tree) - (is_finished(notebook.status_tree) ? 1 : 0), + ], + [notebook.status_tree] + ) + + const busy = status_done < status_total + + const show_business_outline = useDelayedTruth(busy, 700) + const show_business_counter = useDelayedTruth(busy, 3000) + + const my_clock_is_ahead_by = useMyClockIsAheadBy({ connected }) + return html` ` } + +const useDelayedTruth = (/** @type {boolean} */ x, /** @type {number} */ timeout) => { + const [output, set_output] = useState(false) + + useEffect(() => { + if (x) { + let handle = setTimeout(() => { + set_output(true) + }, timeout) + return () => clearTimeout(handle) + } else { + set_output(false) + } + }, [x]) + + return output +} diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index 95a2262185..b1d63f08b8 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -63,6 +63,7 @@ import { HighlightLineFacet, highlightLinePlugin } from "./CellInput/highlight_l import { commentKeymap } from "./CellInput/comment_mixed_parsers.js" import { ScopeStateField } from "./CellInput/scopestate_statefield.js" import { mod_d_command } from "./CellInput/mod_d_command.js" +import { open_bottom_right_panel } from "./BottomRightPanel.js" export const ENABLE_CM_MIXED_PARSER = window.localStorage.getItem("ENABLE_CM_MIXED_PARSER") === "true" @@ -647,7 +648,7 @@ export const CellInput = ({ EditorView.updateListener.of((update) => { if (!update.docChanged) return if (update.state.doc.length > 0 && update.state.sliceDoc(0, 1) === "?") { - window.dispatchEvent(new CustomEvent("open_live_docs")) + open_bottom_right_panel("docs") } }), EditorState.tabSize.of(4), diff --git a/frontend/components/CellInput/pluto_autocomplete.js b/frontend/components/CellInput/pluto_autocomplete.js index 35f9b7e29b..a7b3828c53 100644 --- a/frontend/components/CellInput/pluto_autocomplete.js +++ b/frontend/components/CellInput/pluto_autocomplete.js @@ -16,6 +16,7 @@ import { import { get_selected_doc_from_state } from "./LiveDocsFromCursor.js" import { cl } from "../../common/ClassTable.js" import { ScopeStateField } from "./scopestate_statefield.js" +import { open_bottom_right_panel } from "../BottomRightPanel.js" let { autocompletion, completionKeymap, completionStatus, acceptCompletion } = autocomplete @@ -90,7 +91,7 @@ const tab_completion_command = (cm) => { let open_docs_if_autocomplete_is_open_command = (cm) => { let autocompletion_open = cm.state.field(completionState, false)?.open ?? false if (autocompletion_open) { - window.dispatchEvent(new CustomEvent("open_live_docs")) + open_bottom_right_panel("docs") return true } return false diff --git a/frontend/components/DiscreteProgressBar.js b/frontend/components/DiscreteProgressBar.js new file mode 100644 index 0000000000..729de4b38c --- /dev/null +++ b/frontend/components/DiscreteProgressBar.js @@ -0,0 +1,55 @@ +import { cl } from "../common/ClassTable.js" +import { html, useEffect, useRef, useState } from "../imports/Preact.js" + +export const DiscreteProgressBar = ({ total, done, busy }) => { + total = Math.max(1, total) + + return html` +
= 8 && total < 48, + "big": total >= 48, + })} + data-total=${total} + > + ${[...Array(total)].map((_, i) => { + return html`
= done && i < done + busy, + })} + >
` + })} +
+ ` +} + +export const DiscreteProgressBarTest = () => { + const [done_total, set_done_total] = useState([0, 0, 0]) + + const done_total_ref = useRef(done_total) + done_total_ref.current = done_total + + useEffect(() => { + let handle = setInterval(() => { + const [done, busy, total] = done_total_ref.current + + if (Math.random() < 0.3) { + if (done < total) { + if (Math.random() < 0.1) { + set_done_total([done, 1, total + 5]) + } else { + set_done_total([done + 1, 1, total]) + } + } else { + set_done_total([0, 1, Math.ceil(Math.random() * Math.random() * 100)]) + } + } + }, 100) + return () => clearInterval(handle) + }, []) + + return html`<${DiscreteProgressBar} total=${done_total[2]} busy=${done_total[1]} done=${done_total[0]} />` +} diff --git a/frontend/components/Editor.js b/frontend/components/Editor.js index aa88d45000..a427918b71 100644 --- a/frontend/components/Editor.js +++ b/frontend/components/Editor.js @@ -136,6 +136,16 @@ const first_true_key = (obj) => { * }} */ +/** + * @typedef StatusEntryData + * @type {{ + * name: string, + * started_at: number?, + * finished_at: number?, + * subtasks: Record, + * }} + */ + /** * @typedef CellResultData * @type {{ @@ -233,6 +243,7 @@ const first_true_key = (obj) => { * bonds: BondValuesDict, * nbpkg: NotebookPkgData?, * metadata: object, + * status_tree: StatusEntryData?, * }} */ @@ -1533,6 +1544,7 @@ patch: ${JSON.stringify( <${BottomRightPanel} desired_doc_query=${this.state.desired_doc_query} on_update_doc_query=${this.actions.set_doc_query} + connected=${this.state.connected} notebook=${this.state.notebook} /> <${Popup} diff --git a/frontend/components/ProcessTab.js b/frontend/components/ProcessTab.js new file mode 100644 index 0000000000..3a1db2e935 --- /dev/null +++ b/frontend/components/ProcessTab.js @@ -0,0 +1,255 @@ +import { html, useEffect, useRef, useState } from "../imports/Preact.js" + +import { cl } from "../common/ClassTable.js" +import { prettytime, useMillisSinceTruthy } from "./RunArea.js" +import { DiscreteProgressBar } from "./DiscreteProgressBar.js" + +/** + * @param {{ + * notebook: import("./Editor.js").NotebookData, + * my_clock_is_ahead_by: number, + * }} props + */ +export let ProcessTab = ({ notebook, my_clock_is_ahead_by }) => { + return html` +
+ <${StatusItem} status_tree=${notebook.status_tree} my_clock_is_ahead_by=${my_clock_is_ahead_by} path=${[]} /> +
+ ` +} + +/** + * Status items are sorted in the same order as they appear in list. Unspecified items are sorted to the end. + */ +const global_order = ` +workspace + +create_process +init_process + + +pkg + +analysis +waiting_for_others +resolve +remove +add +instantiate + +run + + +saving + +` + .split("\n") + .map((x) => x.trim()) + .filter((x) => x.length > 0) + +const blocklist = ["saving"] + +/** @type {Record} */ +const descriptions = { + workspace: "Workspace setup", + create_process: "Start Julia", + init_process: "Initialize", + pkg: "Package management", + run: "Evaluating cells", + evaluate: "Running code", + registry_update: "Updating package registry", + waiting_for_others: "Waiting for other notebooks to finish package operations", +} + +export const friendly_name = (/** @type {string} */ task_name) => { + const descr = descriptions[task_name] + + return descr != null ? descr : isnumber(task_name) ? `Step ${task_name}` : task_name +} + +const to_ns = (x) => x * 1e9 + +/** + * @param {{ + * status_tree: import("./Editor.js").StatusEntryData?, + * path: string[], + * my_clock_is_ahead_by: number, + * }} props + */ +const StatusItem = ({ status_tree, path, my_clock_is_ahead_by }) => { + if (status_tree == null) return null + const mystatus = path.reduce((entry, key) => entry.subtasks[key], status_tree) + if (!mystatus) return null + + const [is_open, set_is_open] = useState(path.length < 1) + + const started = path.length > 0 && is_started(mystatus) + const finished = started && is_finished(mystatus) + const busy = started && !finished + + const start = mystatus.started_at ?? 0 + const end = mystatus.finished_at ?? 0 + + const local_busy_time = (useMillisSinceTruthy(busy) ?? 0) / 1000 + const mytime = Date.now() / 1000 + + const busy_time = Math.max(local_busy_time, mytime - my_clock_is_ahead_by - start) + + useEffect(() => { + if (busy) { + let handle = setTimeout(() => { + set_is_open(true) + }, Math.max(100, 500 - path.length * 200)) + + return () => clearTimeout(handle) + } + }, [busy]) + + useEffectWithPrevious( + ([old_finished]) => { + if (!old_finished && finished) { + // let audio = new Audio("https://proxy.notificationsounds.com/message-tones/succeeded-message-tone/download/file-sounds-1210-succeeded.mp3") + // audio.play() + + let handle = setTimeout(() => { + set_is_open(false) + }, 1800 - path.length * 200) + + return () => clearTimeout(handle) + } + }, + [finished] + ) + + const render_child_tasks = () => + Object.entries(mystatus.subtasks) + .sort((a, b) => sort_on(a[1], b[1])) + .map(([key, _subtask]) => + blocklist.includes(key) + ? null + : html`<${StatusItem} key=${key} status_tree=${status_tree} my_clock_is_ahead_by=${my_clock_is_ahead_by} path=${[...path, key]} />` + ) + + const render_child_progress = () => { + let kids = Object.values(mystatus.subtasks) + let done = kids.reduce((acc, x) => acc + (is_finished(x) ? 1 : 0), 0) + let busy = kids.reduce((acc, x) => acc + (is_busy(x) ? 1 : 0), 0) + let total = kids.length + + return html`<${DiscreteProgressBar} busy=${busy} done=${done} total=${total} />` + } + + const inner = is_open + ? // are all kids a numbered task? + Object.values(mystatus.subtasks).every((x) => isnumber(x.name)) && Object.values(mystatus.subtasks).length > 0 + ? render_child_progress() + : render_child_tasks() + : null + + let inner_progress = null + if (started) { + let t = total_tasks(mystatus) + let d = total_done(mystatus) + + if (t > 1) { + inner_progress = html`${" "}(${d}/${t})` + } + } + + return path.length === 0 + ? inner + : html` 0, + })} + > +
{ + set_is_open(!is_open) + }} + > + + ${friendly_name(mystatus.name)}${inner_progress} + ${finished ? prettytime(to_ns(end - start)) : busy ? prettytime(to_ns(busy_time)) : null} +
+ ${inner} +
` +} + +const isnumber = (str) => /^\d+$/.test(str) + +/** + * @param {import("./Editor.js").StatusEntryData} a + * @param {import("./Editor.js").StatusEntryData} b + */ +const sort_on = (a, b) => { + const a_order = global_order.indexOf(a.name) + const b_order = global_order.indexOf(b.name) + if (a_order === -1 && b_order === -1) { + if (a.started_at != null || b.started_at != null) { + return (a.started_at ?? Infinity) - (b.started_at ?? Infinity) + } else if (isnumber(a.name) && isnumber(b.name)) { + return parseInt(a.name) - parseInt(b.name) + } else { + return a.name.localeCompare(b.name) + } + } else { + let m = (x) => (x === -1 ? Infinity : x) + return m(a_order) - m(b_order) + } +} + +/** + * @param {import("./Editor.js").StatusEntryData} status + */ +export const is_finished = (status) => status.finished_at != null + +/** + * @param {import("./Editor.js").StatusEntryData} status + */ +export const is_started = (status) => status.started_at != null + +/** + * @param {import("./Editor.js").StatusEntryData} status + */ +export const is_busy = (status) => is_started(status) && !is_finished(status) + +/** + * @param {import("./Editor.js").StatusEntryData} status + * @returns {number} + */ +export const total_done = (status) => Object.values(status.subtasks).reduce((total, status) => total + total_done(status), is_finished(status) ? 1 : 0) + +/** + * @param {import("./Editor.js").StatusEntryData} status + * @returns {number} + */ +export const total_tasks = (status) => Object.values(status.subtasks).reduce((total, status) => total + total_tasks(status), 1) + +/** + * @param {import("./Editor.js").StatusEntryData} status + * @returns {string[]} + */ +export const path_to_first_busy_business = (status) => { + for (let [name, child_status] of Object.entries(status.subtasks).sort((a, b) => sort_on(a[1], b[1]))) { + if (is_busy(child_status)) { + return [name, ...path_to_first_busy_business(child_status)] + } + } + return [] +} + +/** Like `useEffect`, but the handler function gets the previous deps value as argument. */ +const useEffectWithPrevious = (fn, deps) => { + const ref = useRef(deps) + useEffect(() => { + let result = fn(ref.current) + ref.current = deps + return result + }, deps) +} diff --git a/frontend/components/RunArea.js b/frontend/components/RunArea.js index 2c3bca724e..6cdb33d1a6 100644 --- a/frontend/components/RunArea.js +++ b/frontend/components/RunArea.js @@ -71,7 +71,7 @@ export const RunArea = ({ ` } -const prettytime = (time_ns) => { +export const prettytime = (time_ns) => { if (time_ns == null) { return "---" } diff --git a/frontend/components/welcome/Welcome.js b/frontend/components/welcome/Welcome.js index 6b74400aff..12bcdb638a 100644 --- a/frontend/components/welcome/Welcome.js +++ b/frontend/components/welcome/Welcome.js @@ -70,6 +70,7 @@ export const Welcome = () => { new_update_message(client) // to start JIT'ting + client.send("current_time") client.send("completepath", { query: "" }, {}) }) }, []) diff --git a/frontend/dark_color.css b/frontend/dark_color.css index ab94beabde..a04b2623bb 100644 --- a/frontend/dark_color.css +++ b/frontend/dark_color.css @@ -148,6 +148,11 @@ --code-section-bg-color: rgb(44, 44, 44); --code-section-border-color: #555a64; + --process-item-bg: #443d44; + --process-busy: #ffcd70; + --process-finished: hsl(126deg 30% 60%); + --process-undefined: rgb(151, 151, 151); + /*footer*/ --footer-color: #cacaca; --footer-bg-color: rgb(38, 39, 44); diff --git a/frontend/editor.css b/frontend/editor.css index c75a234dc5..2515861e77 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -2061,8 +2061,7 @@ pluto-helpbox > header { gap: 0.5em; } -pluto-helpbox > header > button.helpbox-tab-key::before { - content: ""; +pluto-helpbox > header > button.helpbox-tab-key > .tabicon { /* font-size: 1em; */ --size: 1.1em; width: var(--size); @@ -2073,23 +2072,109 @@ pluto-helpbox > header > button.helpbox-tab-key::before { margin-right: 0.6em; display: inline-block; } -pluto-helpbox > header > button.helpbox-docs::before { +pluto-helpbox > header > button.helpbox-docs > .tabicon { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/search.svg"); /* content: "📚 "; */ } -pluto-helpbox > header > button.helpbox-docs.active::before { + +pluto-helpbox > header > button.helpbox-docs.active > .tabicon { /* content: "📖 "; */ } -pluto-helpbox > header > button.helpbox-process::before { +pluto-helpbox > header > button.helpbox-process > .tabicon { background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/terminal.svg"); background-image: url("https://cdn.jsdelivr.net/gh/ionic-team/ionicons@5.5.1/src/svg/pulse.svg"); /* content: "💻 "; */ } -pluto-helpbox > header > button.helpbox-tab-key:disabled::before { +pluto-helpbox > header > button.helpbox-tab-key:disabled > .tabicon { /* display: none; */ opacity: 0.5; } +/* +button.helpbox-process.helpbox-tab-key::before { + content: ""; + display: block; + position: absolute; + --offset: -5px; + top: var(--offset); + right: var(--offset); + left: var(--offset); + bottom: var(--offset); + background: var(--process-busy); + border-radius: var(--border-radius); + background: linear-gradient(447deg, var(--process-finished), var(--process-finished) 50%, var(--process-busy) 50%, var(--process-busy)); + background: linear-gradient( + 447deg, + var(--process-busy) 0%, + var(--process-busy) 25%, + var(--process-finished) 25%, + var(--process-finished) 50%, + var(--process-busy) 50%, + var(--process-busy) 75%, + var(--process-finished) 75%, + var(--process-finished) 100% + ); + animation: move-bg 5s linear infinite; + background-size: 100px auto; + z-index: -1; + opacity: 0.6; +} */ + +/* @keyframes rotate-bg { + 0% { + background: linear-gradient( + 0deg, + var(--process-busy) 0%, + var(--process-busy) 20%, + var(--process-finished) 20%, + var(--process-finished) 40%, + var(--process-busy) 40%, + var(--process-busy) 60%, + var(--process-finished) 60%, + var(--process-finished) 80%, + var(--process-busy) 80%, + var(--process-busy) + ); + } + 100% { + background: linear-gradient( + 70deg, + var(--process-busy) 0%, + var(--process-busy) 20%, + var(--process-finished) 20%, + var(--process-finished) 40%, + var(--process-busy) 40%, + var(--process-busy) 60%, + var(--process-finished) 60%, + var(--process-finished) 80%, + var(--process-busy) 80%, + var(--process-busy) + ); + } +} */ +/* +@keyframes move-bg { + 0% { + background-position-x: 0; + } + 100% { + background-position-x: 100px; + } +} + +button.helpbox-process.helpbox-tab-key::after { + content: ""; + display: block; + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + border-radius: var(--border-radius); + border: none; + background: var(--helpbox-header-tab-bg-color); + z-index: -1; +} */ pluto-helpbox .live-docs-searchbox { display: flex; @@ -2129,10 +2214,36 @@ button.helpbox-tab-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + /* position: relative; */ + /* flex: 1 1 auto; */ + /* z-index: 1; */ + /* overflow: hidden; */ +} + +button.helpbox-process.busy { + outline: 6px solid var(--process-busy); +} + +@media (prefers-reduced-motion: no-preference) { + button.helpbox-process.busy { + animation: outline-heartbeat 0.8s ease-in infinite; + animation-direction: alternate; + } +} +@keyframes outline-heartbeat { + 0% { + outline-offset: -1px; + outline-width: 3px; + } + 100% { + outline-offset: 0px; + outline-width: 6px; + } } button.active.helpbox-tab-key { outline: 3px solid #99afb9; + animation: none; } pluto-helpbox > header > button.helpbox-close { @@ -2269,6 +2380,154 @@ pluto-helpbox > section hr { font-size: 1.1em; } +/* PROCESS TAB */ + +pl-status { + --status-color: var(--process-undefined); + font-family: var(--system-ui-font-stack); + display: flex; + flex-direction: column; + border-radius: 0.2em; + /* margin: 0.3em; */ + margin-left: 0.7em; + border-left: 3px solid transparent; + margin-top: 0.4em; + overflow: hidden; + /* background: var(--status-color); */ + /* transition: max-height 1s linear; */ + /* max-height: 30px; */ + flex: 1 0 auto; +} + +pl-status::before { + flex: 1 2 auto; + /* content: ""; */ + display: inline-block; + left: 0; + right: 0px; + width: 3px; + height: 10px; + bottom: 3px; + top: 3px; + background: pink; +} + +pl-status.busy { + --status-color: var(--process-busy); +} +pl-status.finished { + --status-color: var(--process-finished); +} + +pl-status.can_open { + cursor: auto; + border-color: #98989854; +} +pl-status.can_open > div { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + cursor: pointer; +} +pl-status.can_open.is_open { + border-color: var(--status-color); +} + +pl-status[data-depth="0"], +pl-status[data-depth="1"] { + margin-left: 0; +} + +pl-status > div { + display: flex; + flex-direction: row; + align-items: center; + /* margin: 0em 0em 0.4em 0; */ + padding: 0.2em; + background: var(--process-item-bg); + border-radius: 0.4em; + /* flex: 1 0 auto; */ +} + +pl-status > div > .status-icon { + content: ""; + display: inline-block; + width: 1em; + height: 1em; + border-radius: 50%; + background-color: var(--status-color); + /* border: 3px solid green; */ + margin: 0em 0.5em; + flex: 0 0 auto; +} + +pl-status.busy > div > .status-icon { + border: 3px solid transparent; + border-right-color: hsl(126deg 30% 60%); + border-bottom-color: hsl(126deg 30% 60%); + /* border-left-color: hsl(126deg 30% 60%); */ + animation: identifier-spin 1.7s linear infinite; +} + +@keyframes identifier-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.subprogress-counter { + opacity: 0.5; + /* font-variant-numeric: tabular-nums; */ + font-size: 0.8em; +} + +pl-status .status-time { + margin-left: auto; + /* align-self: end; */ + padding-right: 0.5em; + padding-left: 0.5em; + /* font-family: "Roboto Mono", monospace; */ + opacity: 0.6; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.discrete-progress-bar { + display: flex; + flex-direction: row; + background: var(--process-item-bg); + padding: 3px; + border-radius: 4px; + gap: 2px; + contain: strict; + height: 1em; + align-items: stretch; +} + +.discrete-progress-bar > div { + background: var(--process-undefined); + flex: 1 1 auto; + border-radius: 2px; + /* margin: 0.4px; */ +} + +.discrete-progress-bar > div.done { + background: var(--process-finished); +} +.discrete-progress-bar > div.busy { + background: var(--process-busy); +} + +.discrete-progress-bar.mid { + gap: 1px; +} + +.discrete-progress-bar.big { + gap: 0.5px; +} + /* FOOTER */ footer { diff --git a/frontend/editor.js b/frontend/editor.js index f13806e96b..fa0c444ce6 100644 --- a/frontend/editor.js +++ b/frontend/editor.js @@ -94,6 +94,7 @@ export const empty_notebook_state = ({ notebook_id }) => ({ published_objects: {}, bonds: {}, nbpkg: null, + status_tree: { name: "notebook", started_at: null, finished_at: null, subtasks: {} }, }) /** diff --git a/frontend/light_color.css b/frontend/light_color.css index 7ec5ea5fed..e97bf2687d 100644 --- a/frontend/light_color.css +++ b/frontend/light_color.css @@ -5,12 +5,10 @@ /* Color scheme */ --main-bg-color: white; - --rule-color: - rgba(0, 0, 0, 0.15); + --rule-color: rgba(0, 0, 0, 0.15); --kbd-border-color: #dfdfdf; --header-bg-color: white; - --header-border-color: - rgba(0, 0, 0, 0.1); + --header-border-color: rgba(0, 0, 0, 0.1); --ui-button-color: #2a2a2b; --cursor-color: black; --normal-cell: 0, 0, 0; @@ -18,138 +16,88 @@ --error-color: 240, 168, 168; /*Cells*/ - --normal-cell-color: - rgba(var(--normal-cell), 0.1); - --dark-normal-cell-color: - rgba(var(--normal-cell), 0.2); - --selected-cell-color: - rgba(40, 78, 189, 0.4); - --code-differs-cell-color: - rgba(var(--code-differs), 0.68); - --error-cell-color: - rgba(var(--error-color), 0.7); - --bright-error-cell-color: - rgb(var(--error-color)); - --light-error-cell-color: - rgba(var(--error-color), 0.05); + --normal-cell-color: rgba(var(--normal-cell), 0.1); + --dark-normal-cell-color: rgba(var(--normal-cell), 0.2); + --selected-cell-color: rgba(40, 78, 189, 0.4); + --code-differs-cell-color: rgba(var(--code-differs), 0.68); + --error-cell-color: rgba(var(--error-color), 0.7); + --bright-error-cell-color: rgb(var(--error-color)); + --light-error-cell-color: rgba(var(--error-color), 0.05); /*Export styling*/ - --export-bg-color: - rgb(60, 67, 101); - --export-color: - rgba(255, 255, 255, 0.7); - --export-card-bg-color: - rgba(255, 255, 255, 0.8); - --export-card-title-color: - rgba(0, 0, 0, 0.7); - --export-card-text-color: - rgba(0, 0, 0, 0.5); + --export-bg-color: rgb(60, 67, 101); + --export-color: rgba(255, 255, 255, 0.7); + --export-card-bg-color: rgba(255, 255, 255, 0.8); + --export-card-title-color: rgba(0, 0, 0, 0.7); + --export-card-text-color: rgba(0, 0, 0, 0.5); --export-card-shadow-color: #00000029; /*Pluto output styling */ - --pluto-schema-types-color: - rgba(0, 0, 0, 0.4); - --pluto-schema-types-border-color: - rgba(0, 0, 0, 0.2); - --pluto-output-color: - hsl(0, 0%, 25%); - --pluto-output-h-color: - hsl(0, 0%, 12%); + --pluto-schema-types-color: rgba(0, 0, 0, 0.4); + --pluto-schema-types-border-color: rgba(0, 0, 0, 0.2); + --pluto-output-color: hsl(0, 0%, 25%); + --pluto-output-h-color: hsl(0, 0%, 12%); --pluto-output-bg-color: white; --a-underline: #00000059; --blockquote-color: #555; --blockquote-bg: #f2f2f2; --admonition-title-color: white; - --jl-message-color: - rgb(219 233 212); - --jl-message-accent-color: - rgb(158, 200, 137); - --jl-info-color: - rgb(214 227 244); - --jl-info-accent-color: - rgb(148, 182, 226); - --jl-warn-color: - rgb(236 234 213); - --jl-warn-accent-color: - rgb(207, 199, 138); - --jl-danger-color: - rgb(245 218 215); - --jl-danger-accent-color: - rgb(226, 157, 148); - --jl-debug-color: - rgb(245 218 215); - --jl-debug-accent-color: - rgb(226, 157, 148); - --footnote-border-color: - rgba(23, 115, 119, 0.15); - --table-border-color: - rgba(0, 0, 0, 0.2); - --table-bg-hover-color: - rgba(159, 158, 224, 0.15); - --pluto-tree-color: - rgb(0 0 0 / 38%); + --jl-message-color: rgb(219 233 212); + --jl-message-accent-color: rgb(158, 200, 137); + --jl-info-color: rgb(214 227 244); + --jl-info-accent-color: rgb(148, 182, 226); + --jl-warn-color: rgb(236 234 213); + --jl-warn-accent-color: rgb(207, 199, 138); + --jl-danger-color: rgb(245 218 215); + --jl-danger-accent-color: rgb(226, 157, 148); + --jl-debug-color: rgb(245 218 215); + --jl-debug-accent-color: rgb(226, 157, 148); + --footnote-border-color: rgba(23, 115, 119, 0.15); + --table-border-color: rgba(0, 0, 0, 0.2); + --table-bg-hover-color: rgba(159, 158, 224, 0.15); + --pluto-tree-color: rgb(0 0 0 / 38%); /*pluto cell styling*/ - --disabled-cell-bg-color: - rgba(139, 139, 139, 0.25); - --selected-cell-bg-color: - rgba(40, 78, 189, 0.24); - --hover-scrollbar-color-1: - rgba(0, 0, 0, 0.15); - --hover-scrollbar-color-2: - rgba(0, 0, 0, 0.05); + --disabled-cell-bg-color: rgba(139, 139, 139, 0.25); + --selected-cell-bg-color: rgba(40, 78, 189, 0.24); + --hover-scrollbar-color-1: rgba(0, 0, 0, 0.15); + --hover-scrollbar-color-2: rgba(0, 0, 0, 0.05); --skip-as-script-background-color: #ccc; --depends-on-skip-as-script-background-color: #eee; /* Pluto shoulders */ - --shoulder-hover-bg-color: - rgba(0, 0, 0, 0.05); + --shoulder-hover-bg-color: rgba(0, 0, 0, 0.05); /* Logs */ - --pluto-logs-bg-color: - hsl(0deg 0% 98%); - --pluto-logs-key-color: - rgb(0 0 0 / 51%); + --pluto-logs-bg-color: hsl(0deg 0% 98%); + --pluto-logs-key-color: rgb(0 0 0 / 51%); --pluto-logs-progress-fill: #ffffff; --pluto-logs-progress-bg: #e7e7e7; - --pluto-logs-progress-border: - hsl(210deg 16% 74%); + --pluto-logs-progress-border: hsl(210deg 16% 74%); --pluto-logs-info-color: white; --pluto-logs-info-accent-color: inherit; - --pluto-logs-warn-color: - rgb(236 234 213); + --pluto-logs-warn-color: rgb(236 234 213); --pluto-logs-warn-accent-color: #665f26; - --pluto-logs-danger-color: - rgb(245 218 215); - --pluto-logs-danger-accent-color: - rgb(172 66 52); - --pluto-logs-debug-color: - rgb(236 223 247); - --pluto-logs-debug-accent-color: - rgb(100 50 179); + --pluto-logs-danger-color: rgb(245 218 215); + --pluto-logs-danger-accent-color: rgb(172 66 52); + --pluto-logs-debug-color: rgb(236 223 247); + --pluto-logs-debug-accent-color: rgb(100 50 179); /*Top navbar styling*/ --nav-h1-text-color: black; --nav-filepicker-color: #6f6f6f; --nav-filepicker-border-color: #b2b2b2; --nav-process-status-bg-color: white; - --nav-process-status-color: - var(--pluto-output-h-color); + --nav-process-status-color: var(--pluto-output-h-color); /*header*/ - --restart-recc-header-color: - rgba(114, 192, 255, 0.56); - --restart-req-header-color: - rgba(170, 41, 32, 0.56); - --dead-process-header-color: - rgb(230 88 46 / 38%); - --loading-header-color: - hsla(290, 10%, 80%, 0.5); - --disconnected-header-color: - rgba(255, 169, 114, 0.56); - --binder-loading-header-color: - hsl(51deg 64% 90% / 50%); + --restart-recc-header-color: rgba(114, 192, 255, 0.56); + --restart-req-header-color: rgba(170, 41, 32, 0.56); + --dead-process-header-color: rgb(230 88 46 / 38%); + --loading-header-color: hsla(290, 10%, 80%, 0.5); + --disconnected-header-color: rgba(255, 169, 114, 0.56); + --binder-loading-header-color: hsl(51deg 64% 90% / 50%); /*loading bar*/ --loading-grad-color-1: #f1dba9; @@ -161,12 +109,10 @@ --overlay-button-border-save: #f3f2f2; /*input_context_menu*/ - --input-context-menu-border-color: - rgba(0, 0, 0, 0.1); + --input-context-menu-border-color: rgba(0, 0, 0, 0.1); --input-context-menu-bg-color: white; --input-context-menu-soon-color: #55555544; - --input-context-menu-hover-bg-color: - rgba(0, 0, 0, 0.1); + --input-context-menu-hover-bg-color: rgba(0, 0, 0, 0.1); --input-context-menu-li-color: #6b6a6a; /*Pkg status*/ @@ -179,19 +125,15 @@ --pkg-terminal-border-color: #c3c3c3; /* run area*/ - --pluto-runarea-bg-color: - hsl(0, 0, 97%); - --pluto-runarea-span-color: - hsl(353, 5%, 64%); + --pluto-runarea-bg-color: hsl(0, 0, 97%); + --pluto-runarea-span-color: hsl(353, 5%, 64%); /*drop ruler*/ - --dropruler-bg-color: - rgba(0, 0, 0, 0.5); + --dropruler-bg-color: rgba(0, 0, 0, 0.5); /* jlerror */ --jlerror-header-color: #4f1616; - --jlerror-mark-bg-color: - rgb(243 243 243); + --jlerror-mark-bg-color: rgb(243 243 243); --jlerror-a-bg-color: #f5efd9; --jlerror-a-border-left-color: #704141; --jlerror-mark-color: black; @@ -199,20 +141,21 @@ /* helpbox */ --helpbox-bg-color: white; --helpbox-box-shadow-color: #00000010; - --helpbox-header-bg-color: - hsl(0deg 0% 92%); + --helpbox-header-bg-color: hsl(0deg 0% 92%); --helpbox-header-tab-bg-color: white; - --helpbox-header-color: - hsl(230, 14%, 11%); + --helpbox-header-color: hsl(230, 14%, 11%); --helpbox-search-bg-color: #fbfbfb; - --helpbox-search-border-color: - hsl(207deg 24% 87%); - --helpbox-notfound-search-color: - rgb(139, 139, 139); + --helpbox-search-border-color: hsl(207deg 24% 87%); + --helpbox-notfound-search-color: rgb(139, 139, 139); --helpbox-text-color: black; --code-section-bg-color: whitesmoke; --code-section-bg-color: #f3f3f3; + --process-item-bg: #f2f2f2; + --process-busy: #ffcd70; + --process-finished: hsl(126deg 30% 60%); + --process-undefined: rgb(151, 151, 151); + /*footer*/ --footer-color: #333333; --footer-bg-color: #d7dcd3; @@ -220,28 +163,21 @@ --footer-input-border-color: #818181; --footer-filepicker-button-color: white; --footer-filepicker-focus-color: #896c6c; - --footnote-border-color: - rgba(23, 115, 119, 0.15); + --footnote-border-color: rgba(23, 115, 119, 0.15); /* undo delete cell*/ --undo-delete-box-shadow-color: #0083; /*codemirror hints*/ - --cm-editor-tooltip-border-color: - rgba(0, 0, 0, 0.2); + --cm-editor-tooltip-border-color: rgba(0, 0, 0, 0.2); --cm-editor-li-aria-selected-bg-color: #16659d; --cm-editor-li-aria-selected-color: white; - --cm-editor-li-notexported-color: - rgba(0, 0, 0, 0.5); - --code-background: - hsla(46, 90%, 98%, 1); - --cm-code-differs-gutters-color: - rgba(214, 172, 35, 0.2); + --cm-editor-li-notexported-color: rgba(0, 0, 0, 0.5); + --code-background: hsla(46, 90%, 98%, 1); + --cm-code-differs-gutters-color: rgba(214, 172, 35, 0.2); --cm-line-numbers-color: #8d86875e; - --cm-selection-background: - hsl(214deg 100% 73% / 48%); - --cm-selection-background-blurred: - hsl(214deg 0% 73% / 48%); + --cm-selection-background: hsl(214deg 100% 73% / 48%); + --cm-selection-background-blurred: hsl(214deg 0% 73% / 48%); /* code highlighting */ --cm-editor-text-color: #41323f; @@ -256,8 +192,7 @@ --cm-var2-color: #37768a; --cm-builtin-color: #5e7ad3; --cm-function-color: #cc80ac; - --cm-type-color: - hsl(170deg 7% 56%); + --cm-type-color: hsl(170deg 7% 56%); --cm-bracket-color: #41323f; --cm-tag-color: #ef6155; --cm-link-color: #815ba4; @@ -265,21 +200,17 @@ --cm-error-color: #f7f7f7; --cm-matchingBracket-color: black; --cm-matchingBracket-bg-color: #1b4bbb21; - --cm-placeholder-text-color: - rgba(0, 0, 0, 0.2); + --cm-placeholder-text-color: rgba(0, 0, 0, 0.2); /*autocomplete menu*/ --autocomplete-menu-bg-color: white; /* Landing colors */ - --index-text-color: - hsl(0, 0, 60); - --index-clickable-text-color: - hsl(0, 0, 30); + --index-text-color: hsl(0, 0, 60); + --index-clickable-text-color: hsl(0, 0, 30); --docs-binding-bg: #8383830a; --index-card-bg: white; - --welcome-mywork-bg: - hsl(35deg 66% 93%); + --welcome-mywork-bg: hsl(35deg 66% 93%); --welcome-newnotebook-bg: whitesmoke; --welcome-recentnotebook-bg: white; --welcome-recentnotebook-border: #dfdfdf; diff --git a/src/Pluto.jl b/src/Pluto.jl index 01b2878730..125aa188f3 100644 --- a/src/Pluto.jl +++ b/src/Pluto.jl @@ -50,6 +50,7 @@ include("./analysis/ExpressionExplorer.jl") include("./analysis/FunctionDependencies.jl") include("./analysis/ReactiveNode.jl") include("./packages/PkgCompat.jl") +include("./webserver/Status.jl") include("./notebook/Cell.jl") include("./analysis/data structures.jl") diff --git a/src/evaluation/Run.jl b/src/evaluation/Run.jl index 62f5ad4f9f..14d2e2d041 100644 --- a/src/evaluation/Run.jl +++ b/src/evaluation/Run.jl @@ -55,9 +55,13 @@ function run_reactive_core!( )::TopologicalOrder @assert !isready(notebook.executetoken) "run_reactive_core!() was called with a free notebook.executetoken." @assert will_run_code(notebook) - + old_workspace_name, _ = WorkspaceManager.bump_workspace_module((session, notebook)) - + + run_status = Status.report_business_started!(notebook.status_tree, :run) + Status.report_business_started!(run_status, :resolve_topology) + cell_status = Status.report_business_planned!(run_status, :evaluate) + if !is_resolved(new_topology) unresolved_topology = new_topology new_topology = notebook.topology = resolve_topology(session, notebook, unresolved_topology, old_workspace_name; current_roots = setdiff(roots, already_run)) @@ -137,6 +141,12 @@ function run_reactive_core!( send_notebook_changes!(ClientRequest(; session, notebook)) end send_notebook_changes_throttled() + + Status.report_business_finished!(run_status, :resolve_topology) + Status.report_business_started!(cell_status) + for i in eachindex(to_run) + Status.report_business_planned!(cell_status, Symbol(i)) + end # delete new variables that will be defined by a cell unless this cell has already run in the current reactive run to_delete_vars = union!(to_delete_vars, defined_variables(new_topology, new_runnable)...) @@ -158,6 +168,7 @@ function run_reactive_core!( local any_interrupted = false for (i, cell) in enumerate(to_run) + Status.report_business_started!(cell_status, Symbol(i)) cell.queued = false cell.running = true @@ -186,6 +197,7 @@ function run_reactive_core!( end cell.running = false + Status.report_business_finished!(cell_status, Symbol(i)) defined_macros_in_cell = defined_macros(new_topology, cell) |> Set{Symbol} @@ -222,6 +234,7 @@ function run_reactive_core!( notebook.wants_to_interrupt = false flush_notebook_changes() + Status.report_business_finished!(run_status) return new_order end diff --git a/src/evaluation/WorkspaceManager.jl b/src/evaluation/WorkspaceManager.jl index f7cef2aca9..0f16165302 100644 --- a/src/evaluation/WorkspaceManager.jl +++ b/src/evaluation/WorkspaceManager.jl @@ -1,7 +1,8 @@ module WorkspaceManager -import UUIDs: UUID +import UUIDs: UUID, uuid1 import ..Pluto import ..Pluto: Configuration, Notebook, Cell, ProcessStatus, ServerSession, ExpressionExplorer, pluto_filename, Token, withtoken, tamepath, project_relative_path, putnotebookupdates!, UpdateMessage +import ..Pluto.Status import ..Pluto.PkgCompat import ..Configuration: CompilerOptions, _merge_notebook_compiler_options, _convert_to_flags import ..Pluto.ExpressionExplorer: FunctionName @@ -49,13 +50,20 @@ end "Create a workspace for the notebook, optionally in the main process." function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false)::Workspace + workspace_business = is_offline_renderer ? Status.Business(name=:gobble) : Status.report_business_started!(notebook.status_tree, :workspace) + create_status = Status.report_business_started!(workspace_business, :create_process) + Status.report_business_planned!(workspace_business, :init_process) + is_offline_renderer || (notebook.process_status = ProcessStatus.starting) use_distributed = !is_offline_renderer && session.options.evaluation.workspace_use_distributed pid = if use_distributed @debug "Creating workspace process" notebook.path length(notebook.cells) - create_workspaceprocess(; compiler_options=_merge_notebook_compiler_options(notebook, session.options.compiler)) + create_workspaceprocess(; + compiler_options=_merge_notebook_compiler_options(notebook, session.options.compiler), + status=create_status, + ) else pid = Distributed.myid() if !(isdefined(Main, :PlutoRunner) && Main.PlutoRunner isa Module) @@ -66,6 +74,13 @@ function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false end pid end + + Status.report_business_finished!(workspace_business, :create_process) + init_status = Status.report_business_started!(workspace_business, :init_process) + Status.report_business_started!(init_status, Symbol(1)) + Status.report_business_planned!(init_status, Symbol(2)) + Status.report_business_planned!(init_status, Symbol(3)) + Status.report_business_planned!(init_status, Symbol(4)) Distributed.remotecall_eval(Main, [pid], session.options.evaluation.workspace_custom_startup_expr) @@ -101,11 +116,30 @@ function make_workspace((session, notebook)::SN; is_offline_renderer::Bool=false original_ACTIVE_PROJECT, is_offline_renderer, ) + + + Status.report_business_finished!(init_status, Symbol(1)) + Status.report_business_started!(init_status, Symbol(2)) @async start_relaying_logs((session, notebook), remote_log_channel) @async start_relaying_self_updates((session, notebook), run_channel) cd_workspace(workspace, notebook.path) + + Status.report_business_finished!(init_status, Symbol(2)) + Status.report_business_started!(init_status, Symbol(3)) + use_nbpkg_environment((session, notebook), workspace) + + Status.report_business_finished!(init_status, Symbol(3)) + Status.report_business_started!(init_status, Symbol(4)) + + # TODO: precompile 1+1 with display + # sleep(3) + eval_format_fetch_in_workspace(workspace, Expr(:toplevel, LineNumberNode(-1), :(1+1)), uuid1()) + + Status.report_business_finished!(init_status, Symbol(4)) + Status.report_business_finished!(workspace_business, :init_process) + Status.report_business_finished!(workspace_business) is_offline_renderer || if notebook.process_status == ProcessStatus.starting notebook.process_status = ProcessStatus.ready @@ -265,12 +299,18 @@ end # NOTE: this function only start a worker process using given # compiler options, it does not resolve paths for notebooks # compiler configurations passed to it should be resolved before this -function create_workspaceprocess(; compiler_options=CompilerOptions())::Integer +function create_workspaceprocess(; compiler_options=CompilerOptions(), status::Status.Business=Business())::Integer + + Status.report_business_started!(status, Symbol(1)) + Status.report_business_planned!(status, Symbol(2)) # run on proc 1 in case Pluto is being used inside a notebook process # Workaround for "only process 1 can add/remove workers" pid = Distributed.remotecall_eval(Main, 1, quote $(Distributed_expr).addprocs(1; exeflags=$(_convert_to_flags(compiler_options))) |> first end) + + Status.report_business_finished!(status, Symbol(1)) + Status.report_business_started!(status, Symbol(2)) Distributed.remotecall_eval(Main, [pid], process_preamble()) @@ -282,6 +322,8 @@ function create_workspaceprocess(; compiler_options=CompilerOptions())::Integer catch end end end) + + Status.report_business_finished!(status) pid end diff --git a/src/notebook/Notebook.jl b/src/notebook/Notebook.jl index 32013d119e..712152cc4f 100644 --- a/src/notebook/Notebook.jl +++ b/src/notebook/Notebook.jl @@ -4,7 +4,7 @@ import .Configuration import .PkgCompat: PkgCompat, PkgContext import Pkg import TOML - +import .Status const DEFAULT_NOTEBOOK_METADATA = Dict{String, Any}() @@ -55,6 +55,7 @@ Base.@kwdef mutable struct Notebook nbpkg_installed_versions_cache::Dict{String,String}=Dict{String,String}() process_status::String=ProcessStatus.starting + status_tree::Status.Business=_initial_nb_status() wants_to_interrupt::Bool=false last_save_time::Float64=time() last_hot_reload_time::Float64=zero(time()) @@ -64,6 +65,14 @@ Base.@kwdef mutable struct Notebook metadata::Dict{String, Any}=copy(DEFAULT_NOTEBOOK_METADATA) end +function _initial_nb_status() + b = Status.Business(name=:notebook, started_at=time()) + Status.report_business_planned!(b, :workspace) + Status.report_business_planned!(b, :pkg) + Status.report_business_planned!(b, :run) + return b +end + _collect_cells(cells_dict::Dict{UUID,Cell}, cells_order::Vector{UUID}) = map(i -> cells_dict[i], cells_order) _initial_topology(cells_dict::Dict{UUID,Cell}, cells_order::Vector{UUID}) = diff --git a/src/notebook/saving and loading.jl b/src/notebook/saving and loading.jl index 912b6a6c32..b0299ddd48 100644 --- a/src/notebook/saving and loading.jl +++ b/src/notebook/saving and loading.jl @@ -130,8 +130,10 @@ end function save_notebook(notebook::Notebook, path::String) # @warn "Saving to file!!" exception=(ErrorException(""), backtrace()) notebook.last_save_time = time() - write_buffered(path) do io - save_notebook(io, notebook) + Status.report_business!(notebook.status_tree, :saving) do + write_buffered(path) do io + save_notebook(io, notebook) + end end end diff --git a/src/packages/Packages.jl b/src/packages/Packages.jl index 679518f765..9d0847c77b 100644 --- a/src/packages/Packages.jl +++ b/src/packages/Packages.jl @@ -50,6 +50,8 @@ Update the notebook package environment to match the notebook's code. This will: - Detect the use of `Pkg.activate` and enable/disabled nbpkg accordingly. """ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new_topology::NotebookTopology; on_terminal_output::Function=((args...) -> nothing), lag::Real=0) + pkg_status = Status.report_business_started!(notebook.status_tree, :pkg) + Status.report_business_started!(pkg_status, :analysis) 👺 = false @@ -108,12 +110,33 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new can_skip = isempty(removed) && isempty(added) && notebook.nbpkg_ctx_instantiated + Status.report_business_finished!(pkg_status, :analysis) + if !can_skip + wait_business = if !isready(pkg_token) + Status.report_business_started!(pkg_status, + !PkgCompat._updated_registries_compat[] ? + :registry_update : + :waiting_for_others + ) + end + return withtoken(pkg_token) do + isnothing(wait_business) || Status.report_business_finished!(wait_business) + + notebook.nbpkg_ctx_instantiated || Status.report_business_planned!(pkg_status, :resolve) + isempty(removed) || Status.report_business_planned!(pkg_status, :remove) + isempty(added) || Status.report_business_planned!(pkg_status, :add) + if !notebook.nbpkg_ctx_instantiated || !isempty(added) || !isempty(removed) + Status.report_business_planned!(pkg_status, :instantiate) + end + PkgCompat.refresh_registry_cache() if !notebook.nbpkg_ctx_instantiated - resolve_with_auto_fixes(notebook, iolistener) + Status.report_business!(pkg_status, :resolve) do + resolve_with_auto_fixes(notebook, iolistener) + end end to_add = filter(PkgCompat.package_exists, added) @@ -123,6 +146,7 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new @debug "PlutoPkg:" notebook.path to_add to_remove if !isempty(to_remove) + Status.report_business_started!(pkg_status, :remove) # See later comment mkeys() = Set(filter(!is_stdlib, [m.name for m in values(PkgCompat.dependencies(notebook.nbpkg_ctx))])) old_manifest_keys = mkeys() @@ -137,6 +161,7 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new new_manifest_keys = mkeys() # TODO: we might want to upgrade other packages now that constraints have loosened? Does this happen automatically? + Status.report_business_finished!(pkg_status, :remove) end @@ -144,6 +169,7 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new # "Pkg.PRESERVE_DIRECT, but preserve exact verisons of Base.loaded_modules" if !isempty(to_add) + Status.report_business_started!(pkg_status, :add) start_time = time_ns() startlistening(iolistener) PkgCompat.withio(notebook.nbpkg_ctx, IOContext(iolistener.buffer, :color => true)) do @@ -183,11 +209,14 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new end end notebook.nbpkg_install_time_ns = notebook.nbpkg_install_time_ns === nothing ? nothing : (notebook.nbpkg_install_time_ns + (time_ns() - start_time)) + Status.report_business_finished!(pkg_status, :add) @debug "PlutoPkg: done" notebook.path end should_instantiate = !notebook.nbpkg_ctx_instantiated || !isempty(to_add) || !isempty(to_remove) + if should_instantiate + Status.report_business_started!(pkg_status, :instantiate) start_time = time_ns() startlistening(iolistener) PkgCompat.withio(notebook.nbpkg_ctx, IOContext(iolistener.buffer, :color => true)) do @@ -207,10 +236,13 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new popfirst!(LOAD_PATH) end notebook.nbpkg_install_time_ns = notebook.nbpkg_install_time_ns === nothing ? nothing : (notebook.nbpkg_install_time_ns + (time_ns() - start_time)) + Status.report_business_finished!(pkg_status, :instantiate) notebook.nbpkg_ctx_instantiated = true end stoplistening(iolistener) + + Status.report_business_finished!(pkg_status) return ( did_something=👺 || ( @@ -229,6 +261,8 @@ function sync_nbpkg_core(notebook::Notebook, old_topology::NotebookTopology, new end end end + Status.report_business_finished!(pkg_status) + return ( did_something=👺 || (use_plutopkg_old != use_plutopkg_new), used_tier=Pkg.PRESERVE_ALL, @@ -250,6 +284,8 @@ In addition to the steps performed by [`sync_nbpkg_core`](@ref): """ function sync_nbpkg(session, notebook, old_topology::NotebookTopology, new_topology::NotebookTopology; save::Bool=true, take_token::Bool=true) try + Status.report_business_started!(notebook.status_tree, :pkg) + pkg_result = (take_token ? withtoken : (f, _) -> f())(notebook.executetoken) do function iocallback(pkgs, s) notebook.nbpkg_busy_packages = pkgs @@ -306,7 +342,9 @@ function sync_nbpkg(session, notebook, old_topology::NotebookTopology, new_topol send_notebook_changes!(ClientRequest(; session, notebook)) save && save_notebook(session, notebook) - end + finally + Status.report_business_finished!(notebook.status_tree, :pkg) + end end function writebackup(notebook::Notebook) diff --git a/src/webserver/Dynamic.jl b/src/webserver/Dynamic.jl index e0090c4806..1e6790e42b 100644 --- a/src/webserver/Dynamic.jl +++ b/src/webserver/Dynamic.jl @@ -1,6 +1,7 @@ import UUIDs: uuid1 import .PkgCompat +import .Status "Will hold all 'response handlers': functions that respond to a WebSocket request from the client." const responses = Dict{Symbol,Function}() @@ -163,6 +164,7 @@ function notebook_to_js(notebook::Notebook) "instantiated" => notebook.nbpkg_ctx_instantiated, ) end, + "status_tree" => Status.tojs(notebook.status_tree), "cell_execution_order" => cell_id.(collect(topological_order(notebook))), ) end @@ -377,6 +379,10 @@ end # MISC RESPONSES ### +responses[:current_time] = function response_current_time(🙋::ClientRequest) + putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:current_time, Dict(:time => time()), nothing, nothing, 🙋.initiator)) +end + responses[:connect] = function response_connect(🙋::ClientRequest) putclientupdates!(🙋.session, 🙋.initiator, UpdateMessage(:👋, Dict( :notebook_exists => (🙋.notebook !== nothing), diff --git a/src/webserver/SessionActions.jl b/src/webserver/SessionActions.jl index 4d0fd9e2fe..42ba4e976d 100644 --- a/src/webserver/SessionActions.jl +++ b/src/webserver/SessionActions.jl @@ -1,6 +1,6 @@ module SessionActions -import ..Pluto: Pluto, ServerSession, Notebook, Cell, emptynotebook, tamepath, new_notebooks_directory, without_pluto_file_extension, numbered_until_new, cutename, readwrite, update_save_run!, update_from_file, wait_until_file_unchanged, putnotebookupdates!, putplutoupdates!, load_notebook, clientupdate_notebook_list, WorkspaceManager, try_event_call, NewNotebookEvent, OpenNotebookEvent, ShutdownNotebookEvent, @asynclog, ProcessStatus, maybe_convert_path_to_wsl, move_notebook! +import ..Pluto: Pluto, Status, ServerSession, Notebook, Cell, emptynotebook, tamepath, new_notebooks_directory, without_pluto_file_extension, numbered_until_new, cutename, readwrite, update_save_run!, update_from_file, wait_until_file_unchanged, putnotebookupdates!, putplutoupdates!, load_notebook, clientupdate_notebook_list, WorkspaceManager, try_event_call, NewNotebookEvent, OpenNotebookEvent, ShutdownNotebookEvent, @asynclog, ProcessStatus, maybe_convert_path_to_wsl, move_notebook!, throttled using FileWatching import ..Pluto.DownloadCool: download_cool import HTTP @@ -75,8 +75,14 @@ function open(session::ServerSession, path::AbstractString; end session.notebooks[notebook.notebook_id] = notebook - for c in notebook.cells - c.queued = session.options.evaluation.run_notebook_on_load + + run_status = Status.report_business_planned!(notebook.status_tree, :run) + if session.options.evaluation.run_notebook_on_load + Status.report_business_planned!(run_status, :resolve_topology) + for (i,c) in enumerate(notebook.cells) + c.queued = true + Status.report_business_planned!(run_status, Symbol(i)) + end end update_save_run!(session, notebook, notebook.cells; run_async, prerender_text=true) @@ -169,6 +175,11 @@ function add(session::ServerSession, notebook::Notebook; run_async::Bool=true) end end + notebook.status_tree.update_listener_ref[] = first(throttled(1.0 / 20) do + # TODO: this throttle should be trailing + Pluto.send_notebook_changes!(Pluto.ClientRequest(; session, notebook)) + end) + return notebook end diff --git a/src/webserver/Status.jl b/src/webserver/Status.jl new file mode 100644 index 0000000000..0cc196aa9d --- /dev/null +++ b/src/webserver/Status.jl @@ -0,0 +1,115 @@ +module Status + + +Base.@kwdef mutable struct Business + name::Symbol=:ignored + started_at::Union{Nothing,Float64}=nothing + finished_at::Union{Nothing,Float64}=nothing + subtasks::Dict{Symbol,Business}=Dict{Symbol,Business}() + update_listener_ref::Ref{Union{Nothing,Function}}=Ref{Union{Nothing,Function}}(nothing) + lock::Threads.SpinLock=Threads.SpinLock() +end + + + +tojs(b::Business) = Dict{String,Any}( + "name" => b.name, + "started_at" => b.started_at, + "finished_at" => b.finished_at, + "subtasks" => Dict{String,Any}( + String(s) => tojs(r) + for (s, r) in b.subtasks + ), +) + + +function report_business_started!(business::Business) + lock(business.lock) do + business.started_at = time() + business.finished_at = nothing + + empty!(business.subtasks) + end + + isnothing(business.update_listener_ref[]) || business.update_listener_ref[]() + return business +end + + + +function report_business_finished!(business::Business) + lock(business.lock) do + # if it never started, then lets "start" it now + business.started_at = something(business.started_at, time()) + # if it already finished, then leave the old finish time. + business.finished_at = something(business.finished_at, max(business.started_at, time())) + end + + # also finish all subtasks (this can't be inside the same lock) + for v in values(business.subtasks) + report_business_finished!(v) + end + + isnothing(business.update_listener_ref[]) || business.update_listener_ref[]() + + return business +end + + + +create_for_child(parent::Business, name::Symbol) = function() + Business(; name, update_listener_ref=parent.update_listener_ref, lock=parent.lock) +end + +get_child(parent::Business, name::Symbol) = lock(parent.lock) do + get!(create_for_child(parent, name), parent.subtasks, name) +end + +report_business_finished!(parent::Business, name::Symbol) = get_child(parent, name) |> report_business_finished! +report_business_started!(parent::Business, name::Symbol) = get_child(parent, name) |> report_business_started! +report_business_planned!(parent::Business, name::Symbol) = get_child(parent, name) + + +report_business!(f::Function, parent::Business, args...) = try + report_business_started!(parent, args...) + f() +finally + report_business_finished!(parent, args...) +end + + + +# GLOBAL + +# registry update +## once per process + +# waiting for other notebook packages + + + + + + + + + + +# PER NOTEBOOK + +# notebook process starting + +# installing packages +# updating packages + +# running cells + + + + + + +end + + +