Adds config updates and preserves logs when routing (#10)

* adds mod doc, removes unnecessary copyright / license

* removes config form

* uses a relative config path and generates a config if none exists

* adds state, adds `tracing` crate, refactors spawning zebrad and reading its output

* updates `save_config` command to kill the zebrad child process, (which will cause the output reader task to exit as well), read the existing config, overwrite it with the new config, start the zebrad process, and start a new task for reading its output.

* Sleeps for a shorter duration before emitting logs as events

* adds a `read_config` command and a textarea for editing it in the UI

* adds edit mode

* Adds example data, fixes issue with updating

* Adds button styles

* adds some textarea styles

* adds syntax highlighting for config preview with highlightjs

* adds a loading state while Zebrad restarts

* adds tracing logs to save_config command, and wait for zebrad to shutdown before restarting

* moves log state up to App so it persists across route changes, moves wait during setup for webview to start to the async runtime so it doesn't block the setup fn from starting the webview.

* Disallow resizing the config file textarea without resizing the window

---------

Co-authored-by: Automated Release Test <release-tests-no-reply@zfnd.org>
This commit is contained in:
Arya 2024-03-05 21:07:04 -05:00 committed by GitHub
parent b554d659c4
commit 49d8eb2bac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 471 additions and 582 deletions

View File

@ -1,177 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@ -17,10 +17,10 @@
},
"license": "MIT",
"dependencies": {
"@modular-forms/solid": "^0.20.0",
"@solidjs/router": "^0.12.4",
"@tauri-apps/api": ">=2.0.0-beta.0",
"@tauri-apps/plugin-shell": ">=2.0.0-beta.0",
"highlight.js": "^11.9.0",
"solid-js": "^1.7.8",
"solid-styled-components": "^0.28.5",
"vite": "^5.1.5"

View File

@ -8,9 +8,6 @@ overrides:
rollup: npm:@rollup/wasm-node
dependencies:
'@modular-forms/solid':
specifier: ^0.20.0
version: 0.20.0(solid-js@1.8.15)
'@solidjs/router':
specifier: ^0.12.4
version: 0.12.5(solid-js@1.8.15)
@ -20,6 +17,9 @@ dependencies:
'@tauri-apps/plugin-shell':
specifier: '>=2.0.0-beta.0'
version: 2.0.0-beta.1
highlight.js:
specifier: ^11.9.0
version: 11.9.0
solid-js:
specifier: ^1.7.8
version: 1.8.15
@ -478,14 +478,6 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/@modular-forms/solid@0.20.0(solid-js@1.8.15):
resolution: {integrity: sha512-CyXoeo2cwNel2N753c+iDzA+4DgtBrLNAS87DY/aODFZG7QoY0A9YSxdUxRf0N5NDwkzmn1O0XqwMP+EAiOHbA==}
peerDependencies:
solid-js: ^1.3.1
dependencies:
solid-js: 1.8.15
dev: false
/@rollup/wasm-node@4.12.0:
resolution: {integrity: sha512-sqy3+YvV/uWX6bPZOR5PlEdH6xyMPXoelllRQ/uZ13tzy9f4pXZTbajnoWN8IHHXwTNKPiLzsePLiDEVmkxMNw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -814,6 +806,11 @@ packages:
engines: {node: '>=4'}
dev: true
/highlight.js@11.9.0:
resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==}
engines: {node: '>=12.0.0'}
dev: false
/html-entities@2.3.3:
resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
dev: true

1
src-tauri/Cargo.lock generated
View File

@ -4467,4 +4467,5 @@ dependencies = [
"tauri-build",
"tauri-plugin-shell",
"tokio",
"tracing",
]

View File

@ -16,6 +16,7 @@ tauri-plugin-shell = "2.0.0-beta"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.36.0", features = ["full", "tracing", "test-util"] }
tracing = "0.1.40"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!

View File

@ -0,0 +1,116 @@
//! Child process management
use std::{
io::{BufRead, BufReader},
path::PathBuf,
process::{Child, Command, Stdio},
time::Duration,
};
use tauri::{utils, AppHandle, Manager};
use tokio::sync::mpsc::Receiver;
/// Zebrad Configuration Filename
pub const CONFIG_FILE: &str = "zebrad.toml";
#[cfg(windows)]
pub const ZEBRAD_COMMAND_NAME: &str = "zebrad.exe";
#[cfg(not(windows))]
pub const ZEBRAD_COMMAND_NAME: &str = "zebrad";
pub fn zebrad_config_path() -> PathBuf {
let exe_path =
utils::platform::current_exe().expect("could not get path to current executable");
let exe_dir_path = exe_path
.parent()
.expect("could not get path to parent directory of executable");
exe_dir_path.join(CONFIG_FILE)
}
pub fn zebrad_bin_path() -> PathBuf {
let exe_path =
utils::platform::current_exe().expect("could not get path to current executable");
let exe_dir_path = exe_path
.parent()
.expect("could not get path to parent directory of executable");
exe_dir_path.join(ZEBRAD_COMMAND_NAME)
}
pub fn run_zebrad() -> (Child, Receiver<String>) {
let zebrad_config_path = zebrad_config_path();
let zebrad_path = zebrad_bin_path();
let zebrad_config_path_str = zebrad_config_path.display().to_string();
let zebrad_path_str = zebrad_path.display().to_string();
// Generate a default config if there's no existing config file
if !zebrad_config_path.exists() {
Command::new(&zebrad_path_str)
.args(["generate", "-o", &zebrad_config_path_str])
.spawn()
.expect("could not start zebrad to generate default config")
.wait()
.expect("error waiting for `zebrad generate` to exit");
assert!(
zebrad_config_path.exists(),
"config file should exist after `zebrad generate` has exited"
);
}
let mut zebrad_child = Command::new(zebrad_path_str)
.args(["-c", &zebrad_config_path_str])
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("zebrad should be installed as a bundled binary and should start successfully");
let zebrad_stdout = zebrad_child
.stdout
.take()
.expect("should have anonymous pipe");
// Spawn a task for reading output and sending it to a channel
let (output_sender, output_receiver) = tokio::sync::mpsc::channel(100);
let _output_reader_task_handle = tauri::async_runtime::spawn_blocking(move || {
for line in BufReader::new(zebrad_stdout).lines() {
// Ignore send errors for now
if let Err(error) =
output_sender.blocking_send(line.expect("zebrad logs should be valid UTF-8"))
{
tracing::warn!(
?error,
"zebrad output channel is closed before output terminated"
);
}
}
});
(zebrad_child, output_receiver)
}
pub fn spawn_logs_emitter(
mut output_receiver: Receiver<String>,
app_handle: AppHandle,
should_wait_for_webview: bool,
) {
tauri::async_runtime::spawn(async move {
// Wait for webview to start
if should_wait_for_webview {
tokio::time::sleep(Duration::from_secs(3)).await;
}
// Exit the task once the channel is closed and empty.
while let Some(output) = output_receiver.recv().await {
if let Err(error) = app_handle.emit("log", output) {
tracing::warn!(?error, "log could not be serialized to JSON");
}
}
});
}

View File

@ -3,61 +3,66 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{
io::{BufRead, BufReader},
process::{Command, Stdio},
};
use std::{fs, time::Duration};
use tauri::{AppHandle, Manager, RunEvent};
use child_process::{run_zebrad, spawn_logs_emitter, zebrad_config_path};
use tauri::{ipc::InvokeError, AppHandle, Manager, RunEvent};
mod process;
use process::relative_command_path;
mod child_process;
mod state;
use state::AppState;
// TODO: Add a command for updating the config and restarting `zebrad` child process
#[tauri::command]
fn save_config() {}
async fn save_config(app_handle: AppHandle, new_config: String) -> Result<String, InvokeError> {
tracing::info!("dropping and killing zebrad child process");
app_handle.state::<AppState>().kill_zebrad_child();
let zebrad_config_path = zebrad_config_path();
tracing::info!("reading old config");
let old_config_contents = fs::read_to_string(&zebrad_config_path)
.map_err(|err| format!("could not read existing config file, error: {err}"))?;
tracing::info!("writing new config");
fs::write(zebrad_config_path, new_config)
.map_err(|err| format!("could not write to config file, error: {err}"))?;
tracing::info!("waiting for old zebrad child process to shutdown");
tokio::time::sleep(Duration::from_secs(5)).await;
tracing::info!("starting new zebrad child process");
let (zebrad_child, zebrad_output_receiver) = run_zebrad();
tracing::info!("started new zebrad child process, starting output reader task");
app_handle
.state::<AppState>()
.insert_zebrad_child(zebrad_child);
spawn_logs_emitter(zebrad_output_receiver, app_handle, false);
Ok(old_config_contents)
}
#[tauri::command]
fn read_config() -> Result<String, InvokeError> {
Ok(fs::read_to_string(zebrad_config_path())
.map_err(|err| format!("could not read existing config file, error: {err}"))?)
}
fn main() {
// Spawn initial zebrad process
let mut zebrad = Command::new(relative_command_path("zebrad").unwrap())
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("zebrad should be installed as a bundled binary and should start successfully");
// Spawn a task for reading output and sending it to a channel
let (zebrad_log_sender, mut zebrad_log_receiver) = tokio::sync::mpsc::channel(100);
let zebrad_stdout = zebrad.stdout.take().expect("should have anonymous pipe");
// TODO: Use a blocking tokio/async_runtime thread? The io is blocking (reading the child process output from stdio), so
// it shouldn't use a green thread
let _log_emitter_handle = std::thread::spawn(move || {
for line in BufReader::new(zebrad_stdout).lines() {
// Ignore send errors for now
let _ =
zebrad_log_sender.blocking_send(line.expect("zebrad logs should be valid UTF-8"));
}
});
let (zebrad_child, zebrad_output_receiver) = run_zebrad();
tauri::Builder::default()
.manage(AppState::new(zebrad_child))
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
loop {
if let Some(output) = zebrad_log_receiver.recv().await {
app_handle.emit("log", output.clone()).unwrap();
}
}
});
spawn_logs_emitter(zebrad_output_receiver, app.handle().clone(), true);
Ok(())
})
.invoke_handler(tauri::generate_handler![save_config])
.invoke_handler(tauri::generate_handler![save_config, read_config])
.build(tauri::generate_context!())
.unwrap()
.run(move |app_handle: &AppHandle, _event| {
if let RunEvent::Exit = &_event {
zebrad.kill().expect("could not kill zebrad process");
app_handle.state::<AppState>().kill_zebrad_child();
app_handle.exit(0);
}
});

View File

@ -1,28 +0,0 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// Copyright 2024 Zcash Foundation
// This code is modified from the Tauri code.
use tauri::utils;
// See <https://docs.rs/tauri/latest/src/tauri/api/process/command.rs.html#137-145>
pub fn relative_command_path(command: impl std::fmt::Display) -> Option<String> {
let current_exe = utils::platform::current_exe();
let exe_path = match current_exe {
Ok(exe_dir) => exe_dir,
Err(err) => {
println!("error getting path to current executable: {err}");
return None;
}
};
let exe_dir = exe_path.parent()?.display();
#[cfg(windows)]
let path = format!("{}\\{command}.exe", exe_dir);
#[cfg(not(windows))]
let path = format!("{}/{command}", exe_dir);
Some(path)
}

42
src-tauri/src/state.rs Normal file
View File

@ -0,0 +1,42 @@
use std::{process::Child, sync::Mutex};
pub struct AppState {
zebrad_child: Mutex<Option<Child>>,
}
impl AppState {
pub fn new(zebrad_child: Child) -> Self {
Self {
zebrad_child: Mutex::new(Some(zebrad_child)),
}
}
pub fn insert_zebrad_child(&self, new_zebrad_child: Child) {
let mut zebrad_child_handle = self
.zebrad_child
.lock()
.expect("could not get lock on zebrad child mutex");
*zebrad_child_handle = Some(new_zebrad_child);
}
/// Drops and kills the `zebrad_child` child process, if any.
///
/// Returns true if there was a zebrad child process that's been killed and dropped, or
/// returns false if there was no zebrad child process in the state.
pub fn kill_zebrad_child(&self) -> bool {
if let Some(mut zebrad_child) = self
.zebrad_child
.lock()
.expect("could not get lock on zebrad_child mutex")
.take()
{
zebrad_child
.kill()
.expect("could not kill zebrad child process");
true
} else {
false
}
}
}

View File

@ -1,6 +1,13 @@
import { Router, Route, RouteSectionProps, A, useMatch } from "@solidjs/router";
import { css, styled } from "solid-styled-components";
import { listen, Event, UnlistenFn } from "@tauri-apps/api/event";
import { createSignal, onCleanup, onMount } from "solid-js";
import { EXAMPLE_LOGS } from "./tests/example_data";
import { MAX_NUM_LOG_LINES } from "./constants";
import { NAVIGATION_BAR_HEIGHT } from "./constants";
import Logs from "./pages/Logs";
import Configuration from "./pages/Configure";
@ -68,9 +75,56 @@ const AppContainer = ({ children }: RouteSectionProps) => (
);
function App() {
const is_tauri_app = window.hasOwnProperty("__TAURI_INTERNALS__");
const [logs, set_logs] = createSignal<Array<string>>([]);
const is_at_bottom = () => {
const y_bottom = Math.ceil(window.scrollY) + window.innerHeight;
return y_bottom >= document.body.scrollHeight;
};
const scroll_to_bottom = () => {
window.scroll(0, document.body.scrollHeight);
};
if (is_tauri_app) {
let stop_listening: UnlistenFn;
onMount(async () => {
stop_listening = await listen("log", (event: Event<string>) => {
const was_at_bottom = is_at_bottom();
set_logs([...logs().slice(-MAX_NUM_LOG_LINES), event.payload]);
if (was_at_bottom) {
scroll_to_bottom();
}
});
});
onCleanup(() => stop_listening());
} else {
let example_log_index = 0;
setInterval(() => {
const was_at_bottom = is_at_bottom();
set_logs([
...logs().slice(-MAX_NUM_LOG_LINES),
EXAMPLE_LOGS[example_log_index],
]);
// TODO: check if it's the logs page? May be easier to do if this logic is moved to `AppContainer`.
if (was_at_bottom) {
scroll_to_bottom();
}
example_log_index = (example_log_index + 1) % EXAMPLE_LOGS.length;
}, 100);
}
return (
<Router root={AppContainer}>
<Route path="/" component={Logs} />
<Route path="/" component={() => <Logs logs={logs} />} />
<Route path="/configure" component={Configuration} />
</Router>
);

View File

@ -1,70 +0,0 @@
// Modified from <https://github.com/fabian-hiller/modular-forms/blob/main/playgrounds/solid/src/components/Checkbox.tsx>
// Copyright (c) 2022 Fabian Hiller
// Copyright 2024 Zcash Foundation
import { JSX, splitProps } from "solid-js";
import { css } from "solid-styled-components";
type CheckboxProps = {
ref: (element: HTMLInputElement) => void;
name: string;
value?: string;
checked?: boolean;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
required?: boolean;
class?: string;
label: string;
error?: string;
padding?: "none";
};
/**
* Checkbox that allows users to select an option. The label next to the
* checkbox describes the selection option.
*/
export function Checkbox(props: CheckboxProps) {
const [, inputProps] = splitProps(props, [
"class",
"value",
"label",
"error",
"padding",
]);
return (
<div style={{ margin: "16px 0" }}>
<label
class={css`
border: solid 1px ${props.checked ? "#fff" : "#888"};
border-radius: 8px;
display: flex;
`}
>
<span
class={css`
padding 8px 16px;
display: flex;
flex-grow: 1;
color: ${props.checked ? "#fff" : "#888"};
`}
>
{props.label}:
</span>
<input
{...inputProps}
class={css`
margin: auto 8px;
width: 24px;
`}
type="checkbox"
id={props.name}
value={props.value || ""}
checked={props.checked}
aria-invalid={!!props.error}
aria-errormessage={`${props.name}-error`}
/>
</label>
</div>
);
}

View File

@ -1,6 +1,10 @@
import { css, styled } from "solid-styled-components";
import { createForm, getValue } from "@modular-forms/solid";
import { Checkbox } from "../components/checkbox";
import { invoke } from "@tauri-apps/api/core";
import { Accessor, createSignal, onMount } from "solid-js";
import { styled } from "solid-styled-components";
import hljs from "highlight.js";
import { EXAMPLE_CONFIG_CONTENTS } from "../tests/example_data";
const PageContainer = styled("div")`
display: flex;
@ -10,220 +14,138 @@ const PageContainer = styled("div")`
font-family: sans-serif;
`;
const SubmitButton = styled("button")`
display: inline-block;
margin: 16px 0 0;
border-radius: 4px;
padding: 8px 16px;
background: transparent;
color: white;
border: solid 1px white;
cursor: pointer;
`;
type ConsensusConfig = {
checkpoint_sync: boolean;
};
type NetworkConfig = {
listen_addr: string;
network: string;
initial_mainnet_peers: string;
initial_testnet_peers: string;
cache_dir: { enabled: boolean; custom_path?: string };
peerset_initial_target_size: number;
crawl_new_peer_interval: number;
max_connections_per_ip: number;
};
// Note: Interfaces don't seem to work well with @modular-forms
type ZebradConfig = {
consensus: ConsensusConfig;
network: NetworkConfig;
};
const DEFAULT_INITIAL_VALUES = {
consensus: { checkpoint_sync: true },
network: {
listen_addr: "0.0.0.0:8233",
network: "Mainnet",
initial_mainnet_peers: `"dnsseed.z.cash:8233","dnsseed.str4d.xyz:8233","mainnet.seeder.zfnd.org:8233","mainnet.is.yolo.money:8233",`,
initial_testnet_peers: `"dnsseed.testnet.z.cash:18233","testnet.seeder.zfnd.org:18233","testnet.is.yolo.money:18233"`,
cache_dir: {
enabled: true,
},
peerset_initial_target_size: 25,
crawl_new_peer_interval: 61000,
max_connections_per_ip: 1,
},
};
const TextFieldLabel = styled("label")`
border: solid 1px #fff;
const FloatingButtonContainer = styled("div")`
background: #1c1c1c;
position: fixed;
right: 0;
padding: 6px 8px 0 0;
border-radius: 8px;
display: flex;
margin: 16px 0 0;
box-shadow: #1c1c1c 0 0 6px 2px, #1c1c1c 0 0 12px 2px, #1c1c1c 0 0 24px 2px;
`;
const TextFieldLabelSpan = styled("span")`
display: flex;
padding: 8px 16px;
color: #fff;
`;
const TextFieldInput = styled("input")`
padding: 8px 16px;
border: none;
border-radius: 8px;
background: none;
color: #fff;
display: flex;
text-align: right;
flex-grow: 1;
&:focus-visible {
const Button = styled("button")`
outline: none;
border: solid 2px white;
color: white;
padding: 8px 14px;
margin: 12px;
border-radius: 8px;
font-size: 14px;
text-transform: uppercase;
cursor: pointer;
letter-spacing: 1px;
background: transparent;
&:hover {
color: #aaa;
border-color: #aaa;
}
`;
const ConfigTextArea = styled("textarea")`
display: flex;
flex-grow: 1;
background: none;
color: white;
padding: 0 8px;
resize: none;
`;
const ConfigDisplay = ({ children }: { children: Accessor<string> }) => {
const highlighted_code = () =>
hljs.highlight(children(), {
language: "toml",
}).value;
return (
<pre>
<code innerHTML={highlighted_code()} />
</pre>
);
};
const Configuration = () => {
// TODO: Read in this initial value from Zebra's existing config file
// (Generate the default config if none exists)
const [config_store, { Form, Field }] = createForm<ZebradConfig>({
initialValues: DEFAULT_INITIAL_VALUES,
const is_tauri_app = window.hasOwnProperty("__TAURI_INTERNALS__");
const [config_contents, set_config_contents] = createSignal<string>("");
const [edited_config, set_edited_config] = createSignal<string | null>(null);
const [is_saving, set_is_saving] = createSignal<boolean>(false);
onMount(async () => {
if (is_tauri_app) {
set_config_contents(await invoke("read_config"));
} else {
set_config_contents(EXAMPLE_CONFIG_CONTENTS);
}
});
const save_and_apply = (values: ZebradConfig) => {
console.log("save and apply");
console.log(values);
const save_and_apply = async () => {
let new_config = edited_config();
if (new_config === null) {
return;
}
set_config_contents(new_config);
set_edited_config(null);
set_is_saving(true);
if (is_tauri_app) {
await invoke("save_config", { newConfig: new_config });
} else {
await new Promise((resolve) => setTimeout(resolve, 450));
}
set_is_saving(false);
};
const discard_changes = () => {
set_edited_config(null);
};
const start_editing = () => {
set_edited_config(config_contents());
};
const is_editable = () => edited_config() !== null;
return (
<PageContainer>
<h1>Configuration</h1>
<div>
<Form onSubmit={save_and_apply}>
<h5>Consensus:</h5>
<Field name="consensus.checkpoint_sync" type="boolean">
{(field, props) => (
<Checkbox
{...props}
checked={field.value}
error={field.error}
label="Enable Checkpoint Sync"
{is_editable() ? (
<>
<ConfigTextArea
value={edited_config() || ""}
onChange={({ currentTarget: { value } }) =>
set_edited_config(value)
}
/>
<FloatingButtonContainer>
<Button onClick={discard_changes}>Discard Changes</Button>
<Button onClick={save_and_apply}>Save & Apply</Button>
</FloatingButtonContainer>
</>
) : (
<>
<ConfigDisplay>{config_contents}</ConfigDisplay>
<FloatingButtonContainer>
{is_saving() ? (
<span
style={{
padding: "16px",
"margin-top": "16px",
display: "inline-block",
}}
>
Saving and restarting Zebra ...
</span>
) : (
<Button onClick={start_editing}>Edit</Button>
)}
</Field>
<h5>Network:</h5>
<Field name="network.listen_addr" type="string">
{(field, props) => (
<div>
<TextFieldLabel>
<TextFieldLabelSpan class={css``}>
Listen Address:
</TextFieldLabelSpan>
<TextFieldInput
{...props}
type="text"
value={field.value}
class={css``}
/>
</TextFieldLabel>
</div>
</FloatingButtonContainer>
</>
)}
</Field>
<Field name="network.network" type="string">
{(field, props) => (
<div>
<TextFieldLabel>
<TextFieldLabelSpan class={css``}>
Network Type (Mainnet/Testnet):
</TextFieldLabelSpan>
<TextFieldInput
{...props}
type="text"
value={field.value}
class={css``}
/>
</TextFieldLabel>
</div>
)}
</Field>
<Field name="network.initial_mainnet_peers" type="string">
{(field, props) => (
<div>
<TextFieldLabel>
<TextFieldLabelSpan class={css``}>
Initial Mainnet Peers:
</TextFieldLabelSpan>
<TextFieldInput
{...props}
type="text"
value={field.value}
class={css``}
/>
</TextFieldLabel>
</div>
)}
</Field>
<Field name="network.initial_testnet_peers" type="string">
{(field, props) => (
<div>
<TextFieldLabel>
<TextFieldLabelSpan class={css``}>
Initial Testnet Peers:
</TextFieldLabelSpan>
<TextFieldInput
{...props}
type="text"
value={field.value}
class={css``}
/>
</TextFieldLabel>
</div>
)}
</Field>
<Field name="network.cache_dir.enabled" type="boolean">
{(field, props) => (
<Checkbox
{...props}
checked={field.value}
error={field.error}
label="Enable Initial Peer Caching"
/>
)}
</Field>
{getValue(config_store, "network.cache_dir.enabled") ? (
<Field name="network.cache_dir.custom_path" type="string">
{(field, props) => (
<div>
<TextFieldLabel>
<TextFieldLabelSpan class={css``}>
Custom Initial Peer Cache Dir (optional):
</TextFieldLabelSpan>
<TextFieldInput
{...props}
type="text"
value={field.value}
class={css``}
/>
</TextFieldLabel>
</div>
)}
</Field>
) : null}
<SubmitButton type="submit">Save & Apply</SubmitButton>
</Form>
</div>
</PageContainer>
);
};

View File

@ -1,11 +1,6 @@
import { listen, Event, UnlistenFn } from "@tauri-apps/api/event";
import { createSignal, onCleanup, onMount, For } from "solid-js";
import { For, Accessor } from "solid-js";
import { styled } from "solid-styled-components";
import { EXAMPLE_LOGS } from "../tests/example_logs";
import { MAX_NUM_LOG_LINES } from "../constants";
const LogContainer = styled("div")`
display: flex;
flex-grow: 1;
@ -68,53 +63,7 @@ const Log = ({ children }: { children: string }) => {
}
};
const Logs = () => {
const is_tauri_app = window.hasOwnProperty("__TAURI_INTERNALS__");
const [logs, set_logs] = createSignal<Array<string>>([]);
const is_at_bottom = () => {
const y_bottom = Math.ceil(window.scrollY) + window.innerHeight;
return y_bottom >= document.body.scrollHeight;
};
const scroll_to_bottom = () => {
window.scroll(0, document.body.scrollHeight);
};
if (is_tauri_app) {
let stop_listening: UnlistenFn;
onMount(async () => {
stop_listening = await listen("log", (event: Event<string>) => {
const was_at_bottom = is_at_bottom();
set_logs([...logs().slice(-MAX_NUM_LOG_LINES), event.payload]);
if (was_at_bottom) {
scroll_to_bottom();
}
});
});
onCleanup(() => stop_listening());
} else {
let example_log_index = 0;
setInterval(() => {
const was_at_bottom = is_at_bottom();
set_logs([
...logs().slice(-MAX_NUM_LOG_LINES),
EXAMPLE_LOGS[example_log_index],
]);
if (was_at_bottom) {
scroll_to_bottom();
}
example_log_index = (example_log_index + 1) % EXAMPLE_LOGS.length;
}, 100);
}
const Logs = ({ logs }: { logs: Accessor<string[]> }) => {
return (
<LogContainer>
<For each={logs()} fallback={<Log>Waiting for zebrad to start...</Log>}>

View File

@ -12,3 +12,24 @@ body {
color: #f6f6f6;
background-color: #2f2f2f;
}
.hljs-comment {
color: rgb(86, 174, 86);
}
.hljs-section,
.hljs-attr {
color: rgb(151, 190, 229);
}
.hljs-literal {
color: rgb(87, 151, 215);
}
.hljs-string {
color: rgb(225, 143, 118);
}
.hljs-number {
color: rgb(183, 241, 171);
}

View File

@ -23,3 +23,59 @@ export const EXAMPLE_LOGS = [
`2024-02-29T20:01:26.275644Z INFO zebra_state::service::finalized_state::disk_db: the open file limit is high enough for Zebra current_limit=1048576 min_limit=512 ideal_limit=1024`,
`2024-02-29T20:01:26.271188Z INFO zebrad::application: loaded zebrad config config_path=Some("/home/ar/.config/zebrad.toml") config=ZebradConfig { consensus: Config { checkpoint_sync: true }, metrics: Config { endpoint_addr: None }, network: Config { listen_addr: 0.0.0.0:8233, network: Mainnet, initial_mainnet_peers: {"dnsseed.z.cash:8233", "dnsseed.str4d.xyz:8233", "mainnet.seeder.zfnd.org:8233", "mainnet.is.yolo.money:8233"}, initial_testnet_peers: {"dnsseed.testnet.z.cash:18233", "testnet.seeder.zfnd.org:18233", "testnet.is.yolo.money:18233"}, cache_dir: IsEnabled(true), peerset_initial_target_size: 25, crawl_new_peer_interval: 61s, max_connections_per_ip: 1 }, state: Config { cache_dir: "/home/ar/.cache/zebra", ephemeral: false, delete_old_database: true, debug_stop_at_height: None, debug_validity_check_interval: None }, tracing: Config { inner: InnerConfig { use_color: true, force_use_color: false, filter: None, buffer_limit: 128000, endpoint_addr: None, flamegraph: None, progress_bar: None, log_file: None, use_journald: false } }, sync: Config { download_concurrency_limit: 50, checkpoint_verify_concurrency_limit: 1000, full_verify_concurrency_limit: 20, parallel_cpu_threads: 0 }, mempool: Config { tx_cost_limit: 80000000, eviction_memory_time: 3600s, debug_enable_at_height: None }, rpc: Config { listen_addr: None, parallel_cpu_threads: 0, debug_force_finished_sync: false }, mining: Config { miner_address: None, extra_coinbase_data: None, debug_like_zcashd: true }, shielded_scan: Config { sapling_keys_to_scan: 0, db_config: Config { cache_dir: "/home/ar/.cache/zebra-scan", ephemeral: false, delete_old_database: true, debug_stop_at_height: None, debug_validity_check_interval: None } } }`,
];
export const EXAMPLE_CONFIG_CONTENTS = `
# Default configuration for zebrad.
[consensus]
checkpoint_sync = true
[mempool]
eviction_memory_time = "1h"
tx_cost_limit = 80000000
[metrics]
[mining]
debug_like_zcashd = true
[network]
cache_dir = true
crawl_new_peer_interval = "1m 1s"
initial_mainnet_peers = [
"dnsseed.z.cash:8233",
"dnsseed.str4d.xyz:8233",
"mainnet.seeder.zfnd.org:8233",
"mainnet.is.yolo.money:8233",
]
initial_testnet_peers = [
"dnsseed.testnet.z.cash:18233",
"testnet.seeder.zfnd.org:18233",
"testnet.is.yolo.money:18233",
]
listen_addr = "0.0.0.0:8233"
max_connections_per_ip = 1
network = "Mainnet"
peerset_initial_target_size = 25
[rpc]
debug_force_finished_sync = false
parallel_cpu_threads = 0
[state]
cache_dir = "/home/ar/.cache/zebra"
delete_old_database = true
ephemeral = false
[sync]
checkpoint_verify_concurrency_limit = 1000
download_concurrency_limit = 50
full_verify_concurrency_limit = 20
parallel_cpu_threads = 0
[tracing]
buffer_limit = 128000
force_use_color = false
use_color = true
use_journald = false
`;