Leptos -> Dioxus Desktop (#79)

Switch from Leptos to Dioxus, and convert this to be a desktop.

Consequently, the following changes have been made:

- Remove e2e testing (we may have to probably reintroduce this latter after figuring out how to test desktop apps; the code exists in `leptos` branch)
- Remove bunch of server/cli code that are no longer necessary
- Remove `leptos_extra` crate
- `nix_rs`: no longer needs "ssr" feature flags
This commit is contained in:
Sridhar Ratnakumar 2023-10-12 17:56:09 -04:00 committed by GitHub
parent 3882f9a5c6
commit 99086207c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 4425 additions and 2896 deletions

2
.envrc
View File

@ -1,2 +1,2 @@
use flake
nix_direnv_watch_file */*.nix *.nix nix_direnv_watch_file */*.nix *.nix
use flake

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
flake.lock linguist-generated=true
assets/tailwind.css linguist-generated=true

View File

@ -1,18 +0,0 @@
name: "CI"
on:
# Run only when pushing to master branch, and making PRs
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix --accept-flake-config build --no-link --print-out-paths .#e2e-playwright-test
- name: E2E tests
run: nix --accept-flake-config run .#e2e-playwright-test

View File

@ -13,6 +13,9 @@
"rust": "html", "rust": "html",
"*.rs": "html" "*.rs": "html"
}, },
"tailwindCSS.experimental.classRegex": [
"class: \"(.*)\""
],
"files.associations": { "files.associations": {
"*.css": "tailwindcss" "*.css": "tailwindcss"
} }

3688
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,22 +10,16 @@ repository = "https://github.com/juspay/nix-browser"
cfg-if = "1" cfg-if = "1"
clap = { version = "4.3", features = ["derive", "env"] } clap = { version = "4.3", features = ["derive", "env"] }
human-panic = "1.1.5" human-panic = "1.1.5"
leptos = { version = "0.4", features = ["serde", "nightly"] }
leptos_meta = { version = "0.4", features = ["nightly"] }
leptos_router = { version = "0.4", features = ["nightly"] }
leptos_query = "0.2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber-wasm = "0.1"
wasm-bindgen = "=0.2.87" # The version here must match the pinned stuff in Nix flakes.
nix_rs = { version = "0.2", path = "./crates/nix_rs" } nix_rs = { version = "0.2", path = "./crates/nix_rs" }
nix_health = { path = "./crates/nix_health" } nix_health = { path = "./crates/nix_health" }
leptos_extra = { path = "./crates/leptos_extra" }
thiserror = "1.0" thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_with = { version = "3.2", features = ["json"] } serde_with = { version = "3.2", features = ["json"] }
bytesize = { version = "1.3.0", features = ["serde"] } bytesize = { version = "1.3.0", features = ["serde"] }
anyhow = "1.0.75"
[package] [package]
edition = "2021" edition = "2021"
@ -37,89 +31,29 @@ homepage = "https://github.com/juspay/nix-browser"
[package.metadata.docs.rs] [package.metadata.docs.rs]
all-features = true all-features = true
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
axum = { version = "0.6", features = ["json", "tokio"], optional = true } anyhow.workspace = true
axum-macros = { version = "0.3", optional = true }
cfg-if.workspace = true cfg-if.workspace = true
clap.workspace = true clap.workspace = true
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
console_log = { version = "1" } console_log = "1"
http = { version = "0.2", optional = true } http = "0.2"
human-panic.workspace = true human-panic.workspace = true
hyper = { version = "0.14", features = ["server"], optional = true }
leptos.workspace = true
leptos_axum = { version = "0.4", optional = true }
leptos_meta.workspace = true
leptos_router.workspace = true
leptos_query.workspace = true
regex = "1.9.3" regex = "1.9.3"
open = { version = "5.0", optional = true }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_with.workspace = true serde_with.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio = { version = "1.29", features = ["full"], optional = true } tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.4", features = ["full"], optional = true }
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
tracing-subscriber-wasm.workspace = true
uuid = { version = "1.3.0", features = ["serde", "v4", "js"] } uuid = { version = "1.3.0", features = ["serde", "v4", "js"] }
wasm-bindgen.workspace = true
nix_rs.workspace = true nix_rs.workspace = true
nix_health.workspace = true nix_health.workspace = true
leptos_extra.workspace = true dioxus = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
dioxus-desktop = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
[features] dioxus-router = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
default = [ dioxus-signals = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
"ssr", dioxus-std = { version = "0.4.0", features = ["clipboard", "utils"] }
] # Unfortunately, leptos_query won't compile (in `nix build`) without this
hydrate = [
"leptos/hydrate",
"leptos_meta/hydrate",
"leptos_query/hydrate",
"leptos_router/hydrate",
"leptos_extra/hydrate",
]
ssr = [
"dep:axum-macros",
"dep:axum",
"dep:http",
"dep:hyper",
"dep:leptos_axum",
"dep:open",
"dep:tokio",
"dep:tower-http",
"dep:tower",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_query/ssr",
"leptos_router/ssr",
"leptos_extra/ssr",
"nix_rs/ssr",
"nix_health/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
site-addr = "127.0.0.1:3000"
tailwind-input-file = "css/input.css"
assets-dir = "assets"
bin-features = ["ssr"]
lib-features = ["hydrate"]
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

45
Dioxus.toml Normal file
View File

@ -0,0 +1,45 @@
[application]
name = "nix-browser"
default_platform = "desktop"
out_dir = "dist"
asset_dir = "assets"
[web.app]
title = "nix-browser | 🌍"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "assets"]
[web.resource]
# CSS style file
style = ["tailwind.css"]
# Javascript code file
script = []
[web.resource.dev]
# CSS style file
style = []
# Javascript code file
script = []
# FIXME: Need to `cd assets` before running `dx bundle` due to https://github.com/DioxusLabs/dioxus/issues/1283
[bundle]
name = "Nix Browser"
identifier = "in.juspay.nix-browser"
icon = ["images/128x128.png"] # ["32x32.png", "128x128.png", "128x128@2x.png"]
version = "1.0.0"
# TODO: Must add these files
resources = ["**/tailwind.css", "images/**/*.png"] # , "secrets/public_key.txt"]
copyright = "Copyright (c) Juspay 2023. All rights reserved."
category = "Developer Tool"
short_description = "WIP: nix-browser"
long_description = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
"""
osx_frameworks = []

View File

@ -1,6 +1,6 @@
# nix-browser # nix-browser
🚧 This project is a work in progress. The ultimate goal is to create something that inspires people towards [Nix](https://zero-to-flakes.com/). 🚧 This project is a work in progress. The ultimate goal is to create a GUI app that inspires people towards using [Nix](https://zero-to-flakes.com/).
## Getting Started ## Getting Started
@ -15,12 +15,17 @@ This will automatically activate the nix develop shell. Open VSCode and install
## Running locally ## Running locally
In nix shell, We should run two watchers: one for generating Tailwind CSS (`just tw`) and another running the cargo package (`just watch`). In nix shell,
``` ```
# In one terminal,
just watch just watch
# In another,
just tw
``` ```
`just watch` runs `dx serve` (with hot reload disabled) that will restart the desktop app after compilation.
## Nix workflows ## Nix workflows
Inside the nix develop shell (activated by direnv) you can use any of the `cargo` or `rustc` commands, as well as [`just`](https://just.systems/) workflows. Nix specific commands can also be used to work with the project: Inside the nix develop shell (activated by direnv) you can use any of the `cargo` or `rustc` commands, as well as [`just`](https://just.systems/) workflows. Nix specific commands can also be used to work with the project:
@ -38,14 +43,13 @@ nix run
- When you are done with your changes, run `just fmt` to **autoformat** the source tree; the CI checks for this. - When you are done with your changes, run `just fmt` to **autoformat** the source tree; the CI checks for this.
- Add tests if relevant, and run them: - Add tests if relevant, and run them:
- Run `just test` to run the **unit tests**. - Run `just test` to run the **unit tests**.
- Run `just e2e` (requires `just watch` to be running) or `just e2e-release` to run the **end-to-end tests**
- Add documentation wherever useful. To preview the **docs**, run `just doc`. - Add documentation wherever useful. To preview the **docs**, run `just doc`.
## Frontend tech ## Tech
### Rust wasm ### Rust desktop app
We use [Leptos](https://leptos.dev/). With sufficient knowledge of Rust, you can 🎓 read the [Leptos Book](https://leptos-rs.github.io/leptos/) to get familiar with reactive frontend programming in Rust. We use [Dioxus](https://dioxuslabs.com/) to build the desktop app using web technologies. The yet to be released [dioxus-signals](https://github.com/DioxusLabs/dioxus/tree/master/packages/signals) package is also used for data reactivity.
### Styling ### Styling

BIN
assets/images/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

1155
assets/tailwind.css generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
[package]
edition = "2021"
name = "leptos_extra"
version = "0.1.0"
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cfg-if.workspace = true
leptos.workspace = true
leptos_query.workspace = true
tracing.workspace = true
[features]
hydrate = ["leptos/hydrate", "leptos_query/hydrate"]
ssr = ["leptos/ssr", "leptos_query/ssr"]

View File

@ -1,3 +0,0 @@
//! Extra modules for [leptos]
pub mod query;
pub mod signal;

View File

@ -1,104 +0,0 @@
//! [leptos_query] helpers for working with [server] fns, and useful widgets.
use cfg_if::cfg_if;
use leptos::*;
use leptos_query::*;
use std::{future::Future, hash::Hash};
use tracing::instrument;
/// The result type of Leptos [server] function returning a `T`
pub type ServerFnResult<T> = Result<T, ServerFnError>;
/// Sensible [QueryOptions] defaults for an app
pub fn query_options<V>() -> QueryOptions<V> {
QueryOptions {
// Disable staleness so the query is not refetched on every route switch.
stale_time: None,
..Default::default()
}
}
/// Like [use_query] but specifically meant for server functions, does logging
/// via [tracing] and uses [query_options] always.
///
/// In order to be able to log the name of the server fns, we unfortunately must
/// require them to be 1-ary functions taking tuples, due to a limitation with
/// Rust type system around Fn trait.
///
/// Arguments
/// * `k`: The argument to the server fn
/// * `fetcher`: The server fn to call
#[instrument(
name = "use_server_query",
skip(cx, k, fetcher),
fields(
fetcher = std::any::type_name::<F>(),
render_mode=LEPTOS_MODE
)
)]
pub fn use_server_query<K, V, F, Fu>(
cx: Scope,
k: impl Fn() -> K + 'static,
fetcher: F,
) -> QueryResult<ServerFnResult<V>, impl RefetchFn>
where
K: Hash + Eq + Clone + std::fmt::Debug + 'static,
ServerFnResult<V>: Clone + Serializable + 'static,
Fu: Future<Output = ServerFnResult<V>> + 'static,
F: Fn(K) -> Fu + 'static,
{
let span = tracing::Span::current();
tracing::debug!("use_query");
leptos_query::use_query(
cx,
k,
move |k| {
let _enter = span.enter();
tracing::info!("calling server fn");
fetcher(k)
},
query_options::<ServerFnResult<V>>(),
)
}
const LEPTOS_MODE: &str = {
cfg_if! { if #[cfg(feature="ssr")] {
"ssr"
} else if #[cfg(feature="hydrate")] {
"hydrate"
} else {
compile_error!("Either ssr or hydrate feature must be enabled");
}}
};
/// Button component to refresh the given [leptos_query] query.
///
/// Arguments
/// * `result`: The query result to refresh
/// * `query`: The value to pass to [invalidate_query]
#[component]
pub fn RefetchQueryButton<K, V, R, F>(
cx: Scope,
result: QueryResult<ServerFnResult<V>, R>,
query: F,
) -> impl IntoView
where
K: Hash + Eq + Clone + std::fmt::Debug + 'static,
ServerFnResult<V>: Clone + Serializable + 'static,
R: RefetchFn,
F: Fn() -> K + 'static,
{
view! { cx,
<button
class="p-1 text-white shadow border-1 bg-primary-700 disabled:bg-base-400 disabled:text-black"
disabled=move || result.is_fetching.get()
on:click=move |_| {
let k = query();
tracing::debug!("Invalidating query");
use_query_client(cx).invalidate_query::<K, ServerFnResult<V>>(k);
}
>
{move || if result.is_fetching.get() { "Fetching..." } else { "Re-fetch" }}
</button>
}
}

View File

@ -1,78 +0,0 @@
//! [Signal] related helpers for Leptos
use leptos::*;
use tracing::instrument;
/// [provide_context] a new signal of type `T` in the current scope
pub fn provide_signal<T: 'static>(cx: Scope, default: T) {
let sig = create_rw_signal(cx, default);
provide_context(cx, sig);
}
/// [use_context] the signal of type `T` in the current scope
///
/// If the signal was not provided in a top-level scope (via [provide_signal])
/// this method will panic after tracing an error.
#[instrument(name = "use_signal")]
pub fn use_signal<T>(cx: Scope) -> RwSignal<T> {
use_context(cx)
.ok_or_else(|| {
// This happens if the dev forgets to call `provide_signal::<T>` in
// the parent scope
let msg = format!(
"no signal provided for type: {}",
std::any::type_name::<T>()
);
tracing::error!(msg);
msg
})
.unwrap()
}
/// Extends [SignalWith] to add a `with_result` method that operates on the
/// inner value, avoiding the need to clone it.
pub trait SignalWithResult<T, E>: SignalWith<Option<Result<T, E>>> {
/// Like [SignalWith::with] but operates on the inner [Result] value without cloning it.
fn with_result<U>(&self, f: impl Fn(&T) -> U + 'static) -> Option<Result<U, E>>
where
E: Clone,
{
self.with(move |d| d.map_option_result(f))
}
}
impl<T, E> SignalWithResult<T, E> for Signal<Option<Result<T, E>>> {}
/// Functions unique to [Option] of [Result] values
pub trait OptionResult<T, E> {
/// Map the value inside a nested [Option]-of-[Result]
///
/// This function is efficient in that the inner value is not cloned.
fn map_option_result<U>(&self, f: impl Fn(&T) -> U + 'static) -> Option<Result<U, E>>
where
E: Clone;
/// Like [[Option::unwrap_or]] but unwraps the nested value
fn unwrap_option_result_value_or(&self, default: T) -> T
where
T: Clone;
}
impl<T, E> OptionResult<T, E> for Option<Result<T, E>> {
fn map_option_result<U>(&self, f: impl Fn(&T) -> U + 'static) -> Option<Result<U, E>>
where
E: Clone,
{
self.as_ref()
.map(|r| r.as_ref().map(f).map_err(Clone::clone))
}
fn unwrap_option_result_value_or(&self, default: T) -> T
where
T: Clone,
{
self.as_ref()
.and_then(|r| r.as_ref().ok())
.cloned()
.unwrap_or(default)
}
}

View File

@ -12,33 +12,22 @@ crate-type = ["cdylib", "rlib"]
[[bin]] [[bin]]
name = "nix-health" name = "nix-health"
path = "src/main.rs" path = "src/main.rs"
required-features = ["ssr"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
cfg-if.workspace = true cfg-if.workspace = true
clap = { workspace = true, optional = true } clap = { workspace = true }
regex = "1.9.3" regex = "1.9.3"
thiserror.workspace = true thiserror.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_with.workspace = true serde_with.workspace = true
tokio = { version = "1.29", features = ["full"], optional = true } tokio = { version = "1.29", features = ["full"] }
url = { version = "2.4", features = ["serde"] } url = { version = "2.4", features = ["serde"] }
nix_rs.workspace = true nix_rs.workspace = true
human-panic.workspace = true human-panic.workspace = true
anyhow = { version = "1.0.75", optional = true } anyhow = { version = "1.0.75" }
colored = { version = "2.0", optional = true } colored = { version = "2.0" }
which = { version = "4.4.2", optional = true } which = { version = "4.4.2" }
bytesize.workspace = true bytesize.workspace = true
[features]
ssr = [
"dep:clap",
"dep:tokio",
"dep:anyhow",
"dep:colored",
"dep:which",
"nix_rs/ssr",
]

View File

@ -1,9 +1,7 @@
#[cfg(feature = "ssr")]
use nix_rs::{env, info}; use nix_rs::{env, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[cfg(feature = "ssr")]
use crate::traits::*; use crate::traits::*;
/// Check that [nix_rs::config::NixConfig::substituters] is set to a good value. /// Check that [nix_rs::config::NixConfig::substituters] is set to a good value.
@ -22,7 +20,6 @@ impl Default for Caches {
} }
} }
#[cfg(feature = "ssr")]
impl Checkable for Caches { impl Checkable for Caches {
fn check(&self, nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_config.substituters.value; let val = &nix_info.nix_config.substituters.value;

View File

@ -2,9 +2,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::{Check, CheckResult, Checkable}; use crate::traits::{Check, CheckResult, Checkable};
#[cfg(feature = "ssr")]
use nix_rs::{env, info}; use nix_rs::{env, info};
/// Check if direnv is installed /// Check if direnv is installed
@ -25,7 +24,6 @@ impl Default for Direnv {
} }
} }
#[cfg(feature = "ssr")]
impl Checkable for Direnv { impl Checkable for Direnv {
fn check(&self, _nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, _nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> {
let mut checks = vec![]; let mut checks = vec![];
@ -47,7 +45,7 @@ impl Checkable for Direnv {
} }
/// [Check] that direnv was installed. /// [Check] that direnv was installed.
#[cfg(feature = "ssr")]
fn install_check(required: bool) -> Check { fn install_check(required: bool) -> Check {
let suggestion = "Install direnv <https://zero-to-flakes.com/direnv/#setup>".to_string(); let suggestion = "Install direnv <https://zero-to-flakes.com/direnv/#setup>".to_string();
let direnv_install = DirenvInstall::detect(); let direnv_install = DirenvInstall::detect();
@ -67,7 +65,7 @@ fn install_check(required: bool) -> Check {
} }
/// [Check] that direnv was activated on the local flake /// [Check] that direnv was activated on the local flake
#[cfg(feature = "ssr")]
fn activation_check(local_flake: &std::path::Path, required: bool) -> Check { fn activation_check(local_flake: &std::path::Path, required: bool) -> Check {
let suggestion = format!("Run `direnv allow` under `{}`", local_flake.display()); let suggestion = format!("Run `direnv allow` under `{}`", local_flake.display());
Check { Check {
@ -103,7 +101,7 @@ pub struct DirenvInstall {
impl DirenvInstall { impl DirenvInstall {
/// Detect user's direnv installation /// Detect user's direnv installation
#[cfg(feature = "ssr")]
pub fn detect() -> anyhow::Result<Self> { pub fn detect() -> anyhow::Result<Self> {
let bin_path = which::which("direnv")?; let bin_path = which::which("direnv")?;
let output = std::process::Command::new(&bin_path) let output = std::process::Command::new(&bin_path)
@ -136,7 +134,7 @@ impl DirenvInstall {
} }
/// Check if direnv was already activated in [project_dir] /// Check if direnv was already activated in [project_dir]
#[cfg(feature = "ssr")]
pub fn is_direnv_active_on(project_dir: &std::path::Path) -> anyhow::Result<bool> { pub fn is_direnv_active_on(project_dir: &std::path::Path) -> anyhow::Result<bool> {
let output = std::process::Command::new("direnv") let output = std::process::Command::new("direnv")
.arg("status") .arg("status")

View File

@ -1,8 +1,6 @@
#[cfg(feature = "ssr")]
use nix_rs::{env, info}; use nix_rs::{env, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*; use crate::traits::*;
/// Check that [nix_rs::config::NixConfig::experimental_features] is set to a good value. /// Check that [nix_rs::config::NixConfig::experimental_features] is set to a good value.
@ -10,7 +8,6 @@ use crate::traits::*;
#[serde(default)] #[serde(default)]
pub struct FlakeEnabled {} pub struct FlakeEnabled {}
#[cfg(feature = "ssr")]
impl Checkable for FlakeEnabled { impl Checkable for FlakeEnabled {
fn check(&self, nix_info: &info::NixInfo, _nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, nix_info: &info::NixInfo, _nix_env: &env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_config.experimental_features.value; let val = &nix_info.nix_config.experimental_features.value;

View File

@ -1,8 +1,6 @@
#[cfg(feature = "ssr")]
use nix_rs::{env, info}; use nix_rs::{env, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*; use crate::traits::*;
/// Check that [nix_rs::config::NixConfig::max_jobs] is set to a good value. /// Check that [nix_rs::config::NixConfig::max_jobs] is set to a good value.
@ -10,7 +8,6 @@ use crate::traits::*;
#[serde(default)] #[serde(default)]
pub struct MaxJobs {} pub struct MaxJobs {}
#[cfg(feature = "ssr")]
impl Checkable for MaxJobs { impl Checkable for MaxJobs {
fn check(&self, nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> {
let max_jobs = nix_info.nix_config.max_jobs.value; let max_jobs = nix_info.nix_config.max_jobs.value;

View File

@ -1,9 +1,8 @@
use nix_rs::version::NixVersion; use nix_rs::version::NixVersion;
#[cfg(feature = "ssr")]
use nix_rs::{env, info}; use nix_rs::{env, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*; use crate::traits::*;
/// Check that [nix_rs::version::NixVersion] is set to a good value. /// Check that [nix_rs::version::NixVersion] is set to a good value.
@ -25,7 +24,6 @@ impl Default for MinNixVersion {
} }
} }
#[cfg(feature = "ssr")]
impl Checkable for MinNixVersion { impl Checkable for MinNixVersion {
fn check(&self, nix_info: &info::NixInfo, _nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, nix_info: &info::NixInfo, _nix_env: &env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_version; let val = &nix_info.nix_version;

View File

@ -1,11 +1,9 @@
#[cfg(feature = "ssr")]
use nix_rs::{ use nix_rs::{
env::{self, AppleEmulation, MacOSArch, OS}, env::{self, AppleEmulation, MacOSArch, OS},
info, info,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::{Check, CheckResult, Checkable}; use crate::traits::{Check, CheckResult, Checkable};
/// Check if Nix is being run under rosetta emulation /// Check if Nix is being run under rosetta emulation
@ -27,7 +25,6 @@ impl Default for Rosetta {
} }
} }
#[cfg(feature = "ssr")]
impl Checkable for Rosetta { impl Checkable for Rosetta {
fn check(&self, _nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> { fn check(&self, _nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> {
let mut checks = vec![]; let mut checks = vec![];
@ -52,7 +49,7 @@ impl Checkable for Rosetta {
} }
/// Return [AppleEmulation]. Return None if not an ARM mac. /// Return [AppleEmulation]. Return None if not an ARM mac.
#[cfg(feature = "ssr")]
fn get_apple_emulation(system: &OS) -> Option<AppleEmulation> { fn get_apple_emulation(system: &OS) -> Option<AppleEmulation> {
match system { match system {
OS::MacOS { OS::MacOS {

View File

@ -1,7 +1,6 @@
use bytesize::ByteSize; use bytesize::ByteSize;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::{Check, CheckResult, Checkable}; use crate::traits::{Check, CheckResult, Checkable};
/// Check if the system has enough resources /// Check if the system has enough resources
@ -29,7 +28,6 @@ impl Default for System {
} }
} }
#[cfg(feature = "ssr")]
impl Checkable for System { impl Checkable for System {
fn check( fn check(
&self, &self,

View File

@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*; use crate::traits::*;
/// Check that [crate::nix::config::NixConfig::trusted_users] is set to a good value. /// Check that [crate::nix::config::NixConfig::trusted_users] is set to a good value.
@ -8,7 +7,6 @@ use crate::traits::*;
#[serde(default)] #[serde(default)]
pub struct TrustedUsers {} pub struct TrustedUsers {}
#[cfg(feature = "ssr")]
impl Checkable for TrustedUsers { impl Checkable for TrustedUsers {
fn check(&self, nix_info: &nix_rs::info::NixInfo, nix_env: &nix_rs::env::NixEnv) -> Vec<Check> { fn check(&self, nix_info: &nix_rs::info::NixInfo, nix_env: &nix_rs::env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_config.trusted_users.value; let val = &nix_info.nix_config.trusted_users.value;

View File

@ -30,7 +30,6 @@ pub struct NixHealth {
pub direnv: Direnv, pub direnv: Direnv,
} }
#[cfg(feature = "ssr")]
impl<'a> IntoIterator for &'a NixHealth { impl<'a> IntoIterator for &'a NixHealth {
type Item = &'a dyn traits::Checkable; type Item = &'a dyn traits::Checkable;
type IntoIter = std::vec::IntoIter<Self::Item>; type IntoIter = std::vec::IntoIter<Self::Item>;
@ -51,7 +50,6 @@ impl<'a> IntoIterator for &'a NixHealth {
} }
} }
#[cfg(feature = "ssr")]
impl NixHealth { impl NixHealth {
/// Create [NixHealth] using configuration from the given flake /// Create [NixHealth] using configuration from the given flake
/// ///

View File

@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Types that can do specific "health check" for Nix /// Types that can do specific "health check" for Nix
#[cfg(feature = "ssr")]
pub trait Checkable { pub trait Checkable {
/// Run and create the health check /// Run and create the health check
/// ///
/// NOTE: Some checks may perform impure actions (IO, etc.). Returning an /// NOTE: Some checks may perform impure actions (IO, etc.). Returning an
/// empty vector indicates that the check is skipped on this environment. /// empty vector indicates that the check is skipped on this environment.
/// TODO: This should be async!
fn check(&self, nix_info: &nix_rs::info::NixInfo, nix_env: &nix_rs::env::NixEnv) -> Vec<Check>; fn check(&self, nix_info: &nix_rs::info::NixInfo, nix_env: &nix_rs::env::NixEnv) -> Vec<Check>;
} }

View File

@ -20,14 +20,11 @@ thiserror.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_with.workspace = true serde_with.workspace = true
tokio = { version = "1.29", features = ["full"], optional = true } tokio = { version = "1.29", features = ["full"] }
tracing.workspace = true tracing.workspace = true
url = { version = "2.4", features = ["serde"] } url = { version = "2.4", features = ["serde"] }
colored = { version = "2.0", optional = true } colored = { version = "2.0" }
shell-words = { version = "1.1.0", optional = true } shell-words = { version = "1.1.0" }
is_proc_translated = { version = "0.1.1", optional = true } is_proc_translated = { version = "0.1.1" }
sysinfo.version = "0.29.10" sysinfo.version = "0.29.10"
bytesize.workspace = true bytesize.workspace = true
[features]
ssr = ["dep:tokio", "dep:colored", "dep:shell-words", "dep:is_proc_translated"]

View File

@ -12,9 +12,9 @@ use std::fmt::{self, Display};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[cfg(feature = "ssr")]
use tokio::process::Command; use tokio::process::Command;
#[cfg(feature = "ssr")]
use tracing::instrument; use tracing::instrument;
/// The `nix` command's global options. /// The `nix` command's global options.
@ -54,14 +54,13 @@ impl Default for NixCmd {
/// ///
/// The command will be highlighted to distinguish it (for copying) from the /// The command will be highlighted to distinguish it (for copying) from the
/// rest of the instrumentation parameters. /// rest of the instrumentation parameters.
#[cfg(feature = "ssr")]
#[instrument(name = "command")] #[instrument(name = "command")]
pub fn trace_cmd(cmd: &tokio::process::Command) { pub fn trace_cmd(cmd: &tokio::process::Command) {
use colored::Colorize; use colored::Colorize;
tracing::info!("🐚 {}", to_cli(cmd).bright_blue()); tracing::info!("🐚 {}", to_cli(cmd).bright_blue());
} }
#[cfg(feature = "ssr")]
impl NixCmd { impl NixCmd {
/// Return a [Command] for this [NixCmd] configuration /// Return a [Command] for this [NixCmd] configuration
pub fn command(&self) -> Command { pub fn command(&self) -> Command {
@ -72,7 +71,7 @@ impl NixCmd {
} }
/// Run nix with given args, interpreting stdout as JSON, parsing into `T` /// Run nix with given args, interpreting stdout as JSON, parsing into `T`
#[cfg(feature = "ssr")]
pub async fn run_with_args_expecting_json<T>(&self, args: &[&str]) -> Result<T, NixCmdError> pub async fn run_with_args_expecting_json<T>(&self, args: &[&str]) -> Result<T, NixCmdError>
where where
T: serde::de::DeserializeOwned, T: serde::de::DeserializeOwned,
@ -83,7 +82,7 @@ impl NixCmd {
} }
/// Run nix with given args, interpreting parsing stdout, via [std::str::FromStr], into `T` /// Run nix with given args, interpreting parsing stdout, via [std::str::FromStr], into `T`
#[cfg(feature = "ssr")]
pub async fn run_with_args_expecting_fromstr<T>(&self, args: &[&str]) -> Result<T, NixCmdError> pub async fn run_with_args_expecting_fromstr<T>(&self, args: &[&str]) -> Result<T, NixCmdError>
where where
T: std::str::FromStr, T: std::str::FromStr,
@ -96,7 +95,7 @@ impl NixCmd {
} }
/// Run nix with given args, returning stdout. /// Run nix with given args, returning stdout.
#[cfg(feature = "ssr")]
pub async fn run_with_args_returning_stdout( pub async fn run_with_args_returning_stdout(
&self, &self,
args: &[&str], args: &[&str],
@ -132,7 +131,7 @@ impl NixCmd {
} }
/// Convert a Command to user-copyable CLI string /// Convert a Command to user-copyable CLI string
#[cfg(feature = "ssr")]
fn to_cli(cmd: &tokio::process::Command) -> String { fn to_cli(cmd: &tokio::process::Command) -> String {
use std::ffi::OsStr; use std::ffi::OsStr;
let program = cmd.as_std().get_program().to_string_lossy().to_string(); let program = cmd.as_std().get_program().to_string_lossy().to_string();

View File

@ -1,7 +1,7 @@
//! Rust module for `nix show-config` //! Rust module for `nix show-config`
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use tracing::instrument; use tracing::instrument;
use url::Url; use url::Url;
@ -35,7 +35,7 @@ pub struct ConfigVal<T> {
impl NixConfig { impl NixConfig {
/// Get the output of `nix show-config` /// Get the output of `nix show-config`
#[cfg(feature = "ssr")]
#[instrument(name = "show-config")] #[instrument(name = "show-config")]
pub async fn from_nix( pub async fn from_nix(
nix_cmd: &super::command::NixCmd, nix_cmd: &super::command::NixCmd,
@ -47,7 +47,6 @@ impl NixConfig {
} }
} }
#[cfg(feature = "ssr")]
#[tokio::test] #[tokio::test]
async fn test_nix_config() -> Result<(), crate::command::NixCmdError> { async fn test_nix_config() -> Result<(), crate::command::NixCmdError> {
let v = NixConfig::from_nix(&crate::command::NixCmd::default()).await?; let v = NixConfig::from_nix(&crate::command::NixCmd::default()).await?;

View File

@ -26,23 +26,27 @@ pub struct NixEnv {
impl NixEnv { impl NixEnv {
/// Determine [NixEnv] on the user's system /// Determine [NixEnv] on the user's system
#[cfg(feature = "ssr")]
pub async fn detect(current_flake: Option<FlakeUrl>) -> Result<NixEnv, NixEnvError> { pub async fn detect(current_flake: Option<FlakeUrl>) -> Result<NixEnv, NixEnvError> {
use sysinfo::{DiskExt, SystemExt}; use sysinfo::{DiskExt, SystemExt};
let current_user = std::env::var("USER")?;
let os = OS::detect().await; let os = OS::detect().await;
let sys = sysinfo::System::new_with_specifics( tokio::task::spawn_blocking(|| {
sysinfo::RefreshKind::new().with_disks_list().with_memory(), let current_user = std::env::var("USER")?;
); let sys = sysinfo::System::new_with_specifics(
let total_disk_space = to_bytesize(get_nix_disk(&sys)?.total_space()); sysinfo::RefreshKind::new().with_disks_list().with_memory(),
let total_memory = to_bytesize(sys.total_memory()); );
Ok(NixEnv { let total_disk_space = to_bytesize(get_nix_disk(&sys)?.total_space());
current_user, let total_memory = to_bytesize(sys.total_memory());
current_flake, Ok(NixEnv {
os, current_user,
total_disk_space, current_flake,
total_memory, os,
total_disk_space,
total_memory,
})
}) })
.await
.unwrap()
} }
/// Return [NixEnv::current_flake] as a local path if it is one /// Return [NixEnv::current_flake] as a local path if it is one
@ -54,7 +58,7 @@ impl NixEnv {
} }
/// Get the disk where /nix exists /// Get the disk where /nix exists
#[cfg(feature = "ssr")]
fn get_nix_disk(sys: &sysinfo::System) -> Result<&sysinfo::Disk, NixEnvError> { fn get_nix_disk(sys: &sysinfo::System) -> Result<&sysinfo::Disk, NixEnvError> {
use sysinfo::{DiskExt, SystemExt}; use sysinfo::{DiskExt, SystemExt};
let by_mount_point: std::collections::HashMap<&Path, &sysinfo::Disk> = sys let by_mount_point: std::collections::HashMap<&Path, &sysinfo::Disk> = sys
@ -98,7 +102,6 @@ pub enum AppleEmulation {
} }
impl AppleEmulation { impl AppleEmulation {
#[cfg(feature = "ssr")]
pub fn new() -> Self { pub fn new() -> Self {
use is_proc_translated::is_proc_translated; use is_proc_translated::is_proc_translated;
if is_proc_translated() { if is_proc_translated() {
@ -109,7 +112,6 @@ impl AppleEmulation {
} }
} }
#[cfg(feature = "ssr")]
impl Default for AppleEmulation { impl Default for AppleEmulation {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@ -117,7 +119,6 @@ impl Default for AppleEmulation {
} }
impl MacOSArch { impl MacOSArch {
#[cfg(feature = "ssr")]
pub fn from(os_arch: Option<&str>) -> MacOSArch { pub fn from(os_arch: Option<&str>) -> MacOSArch {
match os_arch { match os_arch {
Some("arm64") => MacOSArch::Arm64(AppleEmulation::new()), Some("arm64") => MacOSArch::Arm64(AppleEmulation::new()),
@ -146,7 +147,6 @@ impl Display for OS {
} }
impl OS { impl OS {
#[cfg(feature = "ssr")]
pub async fn detect() -> Self { pub async fn detect() -> Self {
let os_info = tokio::task::spawn_blocking(os_info::get).await.unwrap(); let os_info = tokio::task::spawn_blocking(os_info::get).await.unwrap();
let os_type = os_info.os_type(); let os_type = os_info.os_type();
@ -188,7 +188,7 @@ impl OS {
} }
/// Errors while trying to fetch [NixEnv] /// Errors while trying to fetch [NixEnv]
#[cfg(feature = "ssr")]
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum NixEnvError { pub enum NixEnvError {
#[error("Failed to fetch ENV: {0}")] #[error("Failed to fetch ENV: {0}")]
@ -201,7 +201,7 @@ pub enum NixEnvError {
/// Convert bytes to a closest [ByteSize] /// Convert bytes to a closest [ByteSize]
/// ///
/// Useful for displaying disk space and memory which are typically in GBs / TBs /// Useful for displaying disk space and memory which are typically in GBs / TBs
#[cfg(feature = "ssr")]
fn to_bytesize(bytes: u64) -> ByteSize { fn to_bytesize(bytes: u64) -> ByteSize {
let kb = bytes / 1024; let kb = bytes / 1024;
let mb = kb / 1024; let mb = kb / 1024;
@ -218,7 +218,7 @@ fn to_bytesize(bytes: u64) -> ByteSize {
} }
/// Test for [to_bytesize] /// Test for [to_bytesize]
#[cfg(feature = "ssr")]
#[test] #[test]
fn test_to_bytesize() { fn test_to_bytesize() {
assert_eq!(to_bytesize(0), ByteSize::b(0)); assert_eq!(to_bytesize(0), ByteSize::b(0));

View File

@ -1,5 +1,5 @@
//! Rust module for Nix flakes //! Rust module for Nix flakes
#[cfg(feature = "ssr")]
pub mod eval; pub mod eval;
pub mod outputs; pub mod outputs;
pub mod schema; pub mod schema;
@ -7,11 +7,11 @@ pub mod system;
pub mod url; pub mod url;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use tracing::instrument; use tracing::instrument;
use self::{outputs::FlakeOutputs, schema::FlakeSchema, system::System, url::FlakeUrl}; use self::{outputs::FlakeOutputs, schema::FlakeSchema, system::System, url::FlakeUrl};
#[cfg(feature = "ssr")]
use crate::command::NixCmdError; use crate::command::NixCmdError;
/// All the information about a Nix flake /// All the information about a Nix flake
@ -28,7 +28,7 @@ pub struct Flake {
impl Flake { impl Flake {
/// Get [Flake] info for the given flake url /// Get [Flake] info for the given flake url
#[cfg(feature = "ssr")]
#[instrument(name = "flake", skip(nix_cmd))] #[instrument(name = "flake", skip(nix_cmd))]
pub async fn from_nix( pub async fn from_nix(
nix_cmd: &crate::command::NixCmd, nix_cmd: &crate::command::NixCmd,

View File

@ -1,7 +1,10 @@
//! Nix flake outputs //! Nix flake outputs
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{btree_map::Entry, BTreeMap}; use std::{
collections::{btree_map::Entry, BTreeMap},
fmt::Display,
};
/// Represents the "outputs" of a flake /// Represents the "outputs" of a flake
/// ///
@ -15,7 +18,7 @@ pub enum FlakeOutputs {
impl FlakeOutputs { impl FlakeOutputs {
/// Run `nix flake show` on the given flake url /// Run `nix flake show` on the given flake url
#[cfg(feature = "ssr")]
#[tracing::instrument(name = "flake-show")] #[tracing::instrument(name = "flake-show")]
pub async fn from_nix( pub async fn from_nix(
nix_cmd: &crate::command::NixCmd, nix_cmd: &crate::command::NixCmd,
@ -113,3 +116,9 @@ impl Type {
} }
} }
} }
impl Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!("{:?}", self))
}
}

View File

@ -13,7 +13,7 @@ pub struct NixInfo {
impl NixInfo { impl NixInfo {
/// Determine [NixInfo] on the user's system /// Determine [NixInfo] on the user's system
#[cfg(feature = "ssr")]
pub async fn from_nix( pub async fn from_nix(
nix_cmd: &crate::command::NixCmd, nix_cmd: &crate::command::NixCmd,
) -> Result<NixInfo, crate::command::NixCmdError> { ) -> Result<NixInfo, crate::command::NixCmdError> {

View File

@ -3,11 +3,11 @@ use regex::Regex;
use serde_with::{DeserializeFromStr, SerializeDisplay}; use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{fmt, str::FromStr}; use std::{fmt, str::FromStr};
use thiserror::Error; use thiserror::Error;
#[cfg(feature = "ssr")]
use tracing::instrument; use tracing::instrument;
/// Nix version as parsed from `nix --version` /// Nix version as parsed from `nix --version`
#[derive(Clone, PartialOrd, PartialEq, Eq, Debug, SerializeDisplay, DeserializeFromStr)] #[derive(Clone, Copy, PartialOrd, PartialEq, Eq, Debug, SerializeDisplay, DeserializeFromStr)]
pub struct NixVersion { pub struct NixVersion {
pub major: u32, pub major: u32,
pub minor: u32, pub minor: u32,
@ -48,7 +48,7 @@ impl FromStr for NixVersion {
impl NixVersion { impl NixVersion {
/// Get the output of `nix --version` /// Get the output of `nix --version`
#[cfg(feature = "ssr")]
#[instrument(name = "version")] #[instrument(name = "version")]
pub async fn from_nix( pub async fn from_nix(
nix_cmd: &super::command::NixCmd, nix_cmd: &super::command::NixCmd,
@ -66,7 +66,6 @@ impl fmt::Display for NixVersion {
} }
} }
#[cfg(feature = "ssr")]
#[tokio::test] #[tokio::test]
async fn test_run_nix_version() { async fn test_run_nix_version() {
let nix_version = NixVersion::from_nix(&crate::command::NixCmd::default()) let nix_version = NixVersion::from_nix(&crate::command::NixCmd::default())
@ -75,7 +74,6 @@ async fn test_run_nix_version() {
println!("Nix version: {}", nix_version); println!("Nix version: {}", nix_version);
} }
#[cfg(feature = "ssr")]
#[tokio::test] #[tokio::test]
async fn test_parse_nix_version() { async fn test_parse_nix_version() {
assert_eq!( assert_eq!(

View File

@ -1,7 +1,3 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
a[aria-current="page"] {
@apply font-bold bg-white text-primary-600;
}

4
e2e/.gitignore vendored
View File

@ -1,4 +0,0 @@
node_modules
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -1,6 +0,0 @@
# Playwright end-to-end tests
We use [Playwright](https://playwright.dev/dotnet/) to test our application.
- All e2e test are nixified, simply run `nix run .#e2e-playwright-test` from project root (there are `just` targets as well)
- The nix shell creates a `node_modules` symlink which in turn provide IDE support in VSCode for editing `tests/*`

View File

@ -1,52 +0,0 @@
{ lib, ... }:
{
perSystem = { config, self', pkgs, system, ... }: {
# e2e test service using playwright
process-compose.e2e-playwright-test =
let
TEST_PORT = "5000";
in
{
tui = false;
settings.processes = {
start-app = {
command = "${lib.getExe self'.packages.default} --site-addr=127.0.0.1:${TEST_PORT} --no-open";
readiness_probe = {
exec.command = "${lib.getExe pkgs.curl} --fail 127.0.0.1:${TEST_PORT}";
initial_delay_seconds = 2;
period_seconds = 10;
timeout_seconds = 4;
};
};
test = {
environment = {
inherit TEST_PORT;
};
command = pkgs.writeShellApplication {
name = "e2e-playwright";
runtimeInputs = with pkgs; [ nodejs playwright-test ];
text = ''
cd e2e
playwright test --project chromium
'';
};
depends_on."start-app".condition = "process_healthy";
availability.exit_on_end = true;
};
};
};
devShells.e2e-playwright = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
playwright-test
];
shellHook = ''
export NODE_PATH=${pkgs.playwright-test}/lib/node_modules
# VSCode disrespects NODE_PATH https://github.com/microsoft/TypeScript/issues/8760
# So we must manually create ./node_modules
just node_modules NODE_PATH=$NODE_PATH
'';
};
};
}

1
e2e/node_modules Symbolic link
View File

@ -0,0 +1 @@
/nix/store/ffsk31ifmhjxzf5nvagljxhqylsqzfxv-_at_playwright_slash_test-1.38.0/lib/node_modules

View File

@ -1,68 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'line',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: `http://127.0.0.1:${process.env.TEST_PORT}`,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
}
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View File

@ -1,7 +0,0 @@
import { test, expect } from "@playwright/test";
test('check nix version', async ({ page }) => {
await page.goto('/info');
const nixVersion = await page.locator(":text('Nix Version') + div").textContent();
await expect(nixVersion).toBeTruthy();
});

34
flake.lock generated
View File

@ -38,6 +38,22 @@
"type": "github" "type": "github"
} }
}, },
"dioxus-desktop-template": {
"flake": false,
"locked": {
"lastModified": 1696953583,
"narHash": "sha256-g+8wFUOSVLVN63DAlcj1OhYjOD3pkSFH5BCt6Gs0y6I=",
"owner": "srid",
"repo": "dioxus-desktop-template",
"rev": "a3c76b656c9fbddbde423c800bcab46876b5026e",
"type": "github"
},
"original": {
"owner": "srid",
"repo": "dioxus-desktop-template",
"type": "github"
}
},
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
@ -108,22 +124,6 @@
"type": "github" "type": "github"
} }
}, },
"leptos-fullstack": {
"flake": false,
"locked": {
"lastModified": 1694027158,
"narHash": "sha256-XzDBUA5jzTFDiLJtgvknAb9MuXwEGXmAd/ZoTqVUg+o=",
"owner": "srid",
"repo": "leptos-fullstack",
"rev": "4203055c17476616bec375cd7e471e91f70d26d0",
"type": "github"
},
"original": {
"owner": "srid",
"repo": "leptos-fullstack",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1696234590, "lastModified": 1696234590,
@ -193,8 +193,8 @@
"inputs": { "inputs": {
"cargo-doc-live": "cargo-doc-live", "cargo-doc-live": "cargo-doc-live",
"crane": "crane", "crane": "crane",
"dioxus-desktop-template": "dioxus-desktop-template",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"leptos-fullstack": "leptos-fullstack",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"process-compose-flake": "process-compose-flake", "process-compose-flake": "process-compose-flake",
"rust-overlay": "rust-overlay_2", "rust-overlay": "rust-overlay_2",

View File

@ -18,8 +18,8 @@
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake"; process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
cargo-doc-live.url = "github:srid/cargo-doc-live"; cargo-doc-live.url = "github:srid/cargo-doc-live";
leptos-fullstack.url = "github:srid/leptos-fullstack"; dioxus-desktop-template.url = "github:srid/dioxus-desktop-template";
leptos-fullstack.flake = false; dioxus-desktop-template.flake = false;
}; };
outputs = inputs: outputs = inputs:
@ -30,9 +30,8 @@
inputs.treefmt-nix.flakeModule inputs.treefmt-nix.flakeModule
inputs.process-compose-flake.flakeModule inputs.process-compose-flake.flakeModule
inputs.cargo-doc-live.flakeModule inputs.cargo-doc-live.flakeModule
(inputs.leptos-fullstack + /nix/flake-module.nix) (inputs.dioxus-desktop-template + /nix/flake-module.nix)
./rust.nix ./rust.nix
./e2e/flake-module.nix
]; ];
flake = { flake = {
@ -63,7 +62,6 @@
programs = { programs = {
nixpkgs-fmt.enable = true; nixpkgs-fmt.enable = true;
rustfmt.enable = true; rustfmt.enable = true;
leptosfmt.enable = true;
}; };
}; };
@ -72,7 +70,6 @@
inputsFrom = [ inputsFrom = [
config.treefmt.build.devShell config.treefmt.build.devShell
self'.devShells.rust self'.devShells.rust
self'.devShells.e2e-playwright
]; ];
packages = with pkgs; [ packages = with pkgs; [
just just

View File

@ -6,33 +6,37 @@ default:
# Auto-format the source tree # Auto-format the source tree
fmt: fmt:
treefmt treefmt
# Due a bug in dx fmt, we cannot run it on files using macros
find src/app/ -name \*.rs | grep -v state.rs | grep -v state/ | xargs -n1 sh -c 'echo "📔 $1"; dx fmt -f $1' sh
alias f := fmt alias f := fmt
# CI=true for https://github.com/tauri-apps/tauri/issues/3055#issuecomment-1624389208)
bundle $CI="true":
# HACK (change PWD): Until https://github.com/DioxusLabs/dioxus/issues/1283
cd assets && dx bundle
nix run nixpkgs#eza -- -T ./dist/bundle/macos/nix-browser.app
# Run the project locally # Run the project locally
watch $RUST_BACKTRACE="1": watch $RUST_BACKTRACE="1":
cargo leptos watch # XXX: hot reload doesn't work with tailwind
# dx serve --hot-reload
dx serve
alias w := watch alias w := watch
tw:
tailwind -i ./css/input.css -o ./assets/tailwind.css --watch
# Run 'cargo run' for nix-health CLI in watch mode. Example: just watch-nix-health github:nammayatri/nammayatri # Run 'cargo run' for nix-health CLI in watch mode. Example: just watch-nix-health github:nammayatri/nammayatri
watch-nix-health *ARGS: watch-nix-health *ARGS:
cargo watch -- cargo run --bin nix-health --features=ssr -- {{ARGS}} cargo watch -- cargo run --bin nix-health -- {{ARGS}}
alias wh := watch-nix-health alias wh := watch-nix-health
# Run tests (backend & frontend) # Run tests
test: test:
cargo test cargo test
cargo leptos test
# Run end-to-end tests against release server
e2e-release:
nix run .#e2e-playwright-test
# Run end-to-end tests against `just watch` server
e2e:
cd e2e && TEST_PORT=3000 playwright test --project chromium
# Run docs server (live reloading) # Run docs server (live reloading)
doc: doc:

117
rust.nix
View File

@ -1,83 +1,48 @@
# Nix module for the Rust part of the project # Nix module for the Rust part of the project
# #
# This uses https://github.com/srid/leptos-fullstack/blob/master/nix/flake-module.nix # This uses https://github.com/srid/dioxus-desktop-template/blob/master/nix/flake-module.nix
{ {
perSystem = { config, self', pkgs, lib, system, ... }: perSystem = { config, self', pkgs, lib, system, ... }: {
let dioxus-desktop = {
rustBuildInputs = lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [ overrideCraneArgs = oa: {
IOKit nativeBuildInputs = (oa.nativeBuildInputs or [ ]) ++ [
# For when we start using Tauri pkgs.nix # cargo tests need nix
Carbon
WebKit
]);
in
{
leptos-fullstack.overrideCraneArgs = oa:
let
# 'cargo leptos test' doesn't run tests for all crates in the
# workspace. We do it here.
run-test = pkgs.writeShellApplication {
name = "run-test";
text = ''
set -xe
${oa.cargoTestCommand}
# Disable tests on macOS for https://github.com/garnix-io/issues/issues/69
# If/when we move to Jenkins, this won't be necessary.
${if !pkgs.stdenv.isDarwin
then ''
# Run `cargo test` using the same settings as `cargo leptos test`
# In particular: target-dir and features
cargo test --target-dir=target/server --no-default-features --features=ssr
cargo test --target-dir=target/front --no-default-features --features=hydrate
''
else ""
}
'';
};
in
{
nativeBuildInputs = (oa.nativeBuildInputs or [ ]) ++ [
pkgs.nix # cargo tests need nix
];
buildInputs = (oa.buildInputs or [ ]) ++ rustBuildInputs;
cargoTestCommand = lib.getExe run-test;
meta.description = "WIP: nix-browser";
};
packages = {
default = self'.packages.nix-browser;
nix-health = config.leptos-fullstack.craneLib.buildPackage {
inherit (config.leptos-fullstack) src;
pname = "nix-health";
nativeBuildInputs = [
pkgs.nix # cargo tests need nix
];
buildInputs = rustBuildInputs;
cargoExtraArgs = "-p nix_health --features ssr";
# Disable tests on macOS for https://github.com/garnix-io/issues/issues/69
# If/when we move to Jenkins, this won't be necessary.
doCheck = !pkgs.stdenv.isDarwin;
};
};
devShells.rust = pkgs.mkShell {
inputsFrom = [
self'.devShells.nix-browser
]; ];
packages = with pkgs; [ meta.description = "WIP: nix-browser";
cargo-watch
cargo-expand
cargo-nextest
config.process-compose.cargo-doc-live.outputs.package
];
buildInputs = rustBuildInputs;
shellHook = ''
echo
echo "🍎🍎 Run 'just <recipe>' to get started"
just
'';
}; };
}; };
packages = {
default = self'.packages.nix-browser;
nix-health = config.dioxus-desktop.craneLib.buildPackage {
inherit (config.dioxus-desktop) src;
pname = "nix-health";
nativeBuildInputs = [
pkgs.nix # cargo tests need nix
];
buildInputs = config.dioxus-desktop.rustBuildInputs;
cargoExtraArgs = "-p nix_health";
# Disable tests on macOS for https://github.com/garnix-io/issues/issues/69
# If/when we move to Jenkins, this won't be necessary.
doCheck = !pkgs.stdenv.isDarwin;
};
};
devShells.rust = pkgs.mkShell {
inputsFrom = [
self'.devShells.nix-browser
];
packages = with pkgs; [
cargo-watch
cargo-expand
cargo-nextest
config.process-compose.cargo-doc-live.outputs.package
];
shellHook = ''
echo
echo "🍎🍎 Run 'just <recipe>' to get started"
just
'';
};
};
} }

View File

@ -2,203 +2,162 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use leptos::*; use dioxus::prelude::*;
use leptos_extra::{ use dioxus_router::prelude::Link;
query::{self, RefetchQueryButton}, use nix_rs::flake::{
signal::{use_signal, SignalWithResult}, outputs::{FlakeOutputs, Type, Val},
}; schema::FlakeSchema,
use leptos_meta::*; url::FlakeUrl,
use leptos_router::*; Flake,
use nix_rs::{
command::Refresh,
flake::{
outputs::{FlakeOutputs, Type, Val},
schema::FlakeSchema,
url::FlakeUrl,
Flake,
},
}; };
use crate::widget::*; use crate::{
app::widget::RefreshButton,
app::{state::AppState, Route},
};
/// Nix flake dashboard
#[component] #[component]
pub fn NixFlakeRoute(cx: Scope) -> impl IntoView { pub fn Flake(cx: Scope) -> Element {
let suggestions = FlakeUrl::suggestions(); let state = AppState::use_state(cx);
let url = use_signal::<FlakeUrl>(cx); let fut = use_future(cx, (), |_| async move { state.update_flake().await });
let refresh = use_signal::<Refresh>(cx); let flake = state.flake.read();
let query = move || (url(), refresh()); let busy = (*flake).is_loading_or_refreshing();
let result = query::use_server_query(cx, query, get_flake); render! {
view! { cx, h1 { class: "text-5xl font-bold", "Flake dashboard" }
<Title text="Nix Flake"/> div { class: "p-2 my-1",
<h1 class="text-5xl font-bold">{"Nix Flake"}</h1> input {
<TextInput id="nix-flake-input" label="Load a Nix Flake" val=url suggestions/> class: "w-full p-1 mb-4 font-mono",
<RefetchQueryButton result query/> id: "nix-flake-input",
<Outlet/> "type": "text",
value: "{state.flake_url}",
disabled: busy,
onchange: move |ev| {
let url: FlakeUrl = ev.value.clone().into();
tracing::info!("setting flake url set to {}", & url);
state.flake_url.set(url);
fut.restart();
}
}
RefreshButton { busy: busy, handler: move |_| { fut.restart() } }
flake.render_with(cx, |v| render! { FlakeView { flake: v.clone() } })
}
} }
} }
#[component] #[component]
pub fn NixFlakeHomeRoute(cx: Scope) -> impl IntoView { pub fn FlakeRaw(cx: Scope) -> Element {
let url = use_signal::<FlakeUrl>(cx); let state = AppState::use_state(cx);
let refresh = use_signal::<Refresh>(cx); use_future(cx, (), |_| async move { state.update_flake().await });
let query = move || (url(), refresh()); let flake = state.flake.read();
let result = query::use_server_query(cx, query, get_flake); render! {
let data = result.data; div {
view! { cx, Link { to: Route::Flake {}, "⬅ Back" }
<div class="p-2 my-1"> div { class: "px-4 py-2 font-mono text-xs text-left text-gray-500 border-2 border-black",
<SuspenseWithErrorHandling> flake.render_with(cx, |v| render! { FlakeOutputsRawView { outs: v.output.clone() } } )
{move || { }
data.with_result(move |flake| { }
view! { cx, <FlakeView flake/> }
})
}}
</SuspenseWithErrorHandling>
</div>
} }
} }
#[component] #[component]
pub fn NixFlakeRawRoute(cx: Scope) -> impl IntoView { pub fn FlakeView(cx: Scope, flake: Flake) -> Element {
let url = use_signal::<FlakeUrl>(cx); render! {
let refresh = use_signal::<Refresh>(cx); div { class: "flex flex-col my-4",
let query = move || (url(), refresh()); h3 { class: "text-lg font-bold", flake.url.to_string() }
let result = query::use_server_query(cx, query, get_flake); div { class: "text-sm italic text-gray-600",
let data = result.data; Link { to: Route::FlakeRaw {}, "View raw output" }
view! { cx, }
<div> div { FlakeSchemaView { schema: &flake.schema } }
<A href="/flake">"< Back"</A> }
</div>
<div class="px-4 py-2 font-mono text-xs text-left text-gray-500 border-2 border-black">
<SuspenseWithErrorHandling>
{move || {
data.with_result(move |r| {
view! { cx, <FlakeOutputsRawView outs=&r.output/> }
})
}}
</SuspenseWithErrorHandling>
</div>
} }
} }
#[component] #[component]
fn FlakeView<'a>(cx: Scope, flake: &'a Flake) -> impl IntoView { pub fn SectionHeading(cx: Scope, title: &'static str) -> Element {
view! { cx, render! {
<div class="flex flex-col my-4"> h3 { class: "p-2 mt-4 mb-2 font-bold bg-gray-300 border-b-2 border-l-2 border-black text-l",
<h3 class="text-lg font-bold">{flake.url.to_string()}</h3> "{title}"
<div class="text-sm italic text-gray-600"> }
<A href="/flake/raw" exact=true>
"View raw output"
</A>
</div>
<div>
<FlakeSchemaView schema=&flake.schema/>
</div>
</div>
} }
} }
#[component] #[component]
fn SectionHeading(cx: Scope, title: &'static str) -> impl IntoView { pub fn FlakeSchemaView<'a>(cx: Scope, schema: &'a FlakeSchema) -> Element {
view! { cx, let system = schema.system.clone();
<h3 class="p-2 mt-4 mb-2 font-bold bg-gray-300 border-b-2 border-l-2 border-black text-l"> render! {
{title} div {
</h3> h2 { class: "my-2",
div { class: "text-xl font-bold text-primary-600", "{system.human_readable()}" }
span { class: "font-mono text-xs text-gray-500", "(", "{system }", ")" }
}
div { class: "text-left",
BtreeMapView { title: "Packages", tree: &schema.packages }
BtreeMapView { title: "Legacy Packages", tree: &schema.legacy_packages }
BtreeMapView { title: "Dev Shells", tree: &schema.devshells }
BtreeMapView { title: "Checks", tree: &schema.checks }
BtreeMapView { title: "Apps", tree: &schema.apps }
SectionHeading { title: "Formatter" }
match schema.formatter.as_ref() {
Some(v) => {
let k = v.name.clone().unwrap_or("formatter".to_string());
render! { FlakeValView { k: k.clone(), v: v.clone() } }
},
None => render! { "" }
},
SectionHeading { title: "Other" }
match &schema.other {
Some(v) => render! { FlakeOutputsRawView { outs: FlakeOutputs::Attrset(v.clone()) } },
None => render! { "" }
}
}
}
} }
} }
#[component] #[component]
fn FlakeSchemaView<'a>(cx: Scope, schema: &'a FlakeSchema) -> impl IntoView { pub fn BtreeMapView<'a>(
let system = &schema.system.clone();
view! { cx,
<div>
<h2 class="my-2 ">
<div class="text-xl font-bold text-primary-600">{system.human_readable()}</div>
" "
<span class="font-mono text-xs text-gray-500">"(" {system.to_string()} ")"</span>
</h2>
<div class="text-left">
<BTreeMapView title="Packages" tree=&schema.packages/>
<BTreeMapView title="Legacy Packages" tree=&schema.legacy_packages/>
<BTreeMapView title="Dev Shells" tree=&schema.devshells/>
<BTreeMapView title="Checks" tree=&schema.checks/>
<BTreeMapView title="Apps" tree=&schema.apps/>
<SectionHeading title="Formatter"/>
{schema
.formatter
.as_ref()
.map(|v| {
let default = "formatter".to_string();
let k = v.name.as_ref().unwrap_or(&default);
view! { cx, <FlakeValView k v/> }
})}
<SectionHeading title="Other"/>
{schema
.other
.as_ref()
.map(|v| {
// TODO: Use a non-recursive rendering component?
view! { cx, <FlakeOutputsRawView outs=&FlakeOutputs::Attrset(v.clone())/> }
})}
</div>
</div>
}
}
#[component]
fn BTreeMapView<'a>(
cx: Scope, cx: Scope,
title: &'static str, title: &'static str,
tree: &'a BTreeMap<String, Val>, tree: &'a BTreeMap<String, Val>,
) -> impl IntoView { ) -> Element {
(!tree.is_empty()).then(move || { render! {
view! { cx, div {
<SectionHeading title/> SectionHeading { title: title }
<BTreeMapBodyView tree/> BtreeMapBodyView { tree: tree }
} }
})
}
#[component]
fn BTreeMapBodyView<'a>(cx: Scope, tree: &'a BTreeMap<String, Val>) -> impl IntoView {
view! { cx,
<div class="flex flex-wrap justify-start">
{tree.iter().map(|(k, v)| view! { cx, <FlakeValView k v/> }).collect_view(cx)}
</div>
} }
} }
#[component] #[component]
fn FlakeValView<'a>(cx: Scope, k: &'a String, v: &'a Val) -> impl IntoView { pub fn BtreeMapBodyView<'a>(cx: Scope, tree: &'a BTreeMap<String, Val>) -> Element {
view! { cx, render! {
<div div { class: "flex flex-wrap justify-start",
title=format!("{:?}", v.type_) for (k , v) in tree.iter() {
class="flex flex-col p-2 my-2 mr-2 space-y-2 bg-white border-4 border-gray-300 rounded hover:border-gray-400" FlakeValView { k: k.clone(), v: v.clone() }
> }
<div class="flex flex-row justify-start space-x-2 font-bold text-primary-500"> }
<div>{v.type_.to_icon()}</div> }
<div>{k}</div> }
</div>
{v
.name
.as_ref()
.map(|v| {
view! { cx, <div class="font-mono text-xs text-gray-500">{v}</div> }
})}
{v #[component]
.description pub fn FlakeValView(cx: Scope, k: String, v: Val) -> Element {
.as_ref() render! {
.map(|v| { div {
view! { cx, <div class="font-light">{v}</div> } title: "{v.type_}",
})} class: "flex flex-col p-2 my-2 mr-2 space-y-2 bg-white border-4 border-gray-300 rounded hover:border-gray-400",
div { class: "flex flex-row justify-start space-x-2 font-bold text-primary-500",
</div> div { v.type_.to_icon() }
div { "{k}" }
}
match &v.name {
Some(name_val) => render! { div { class: "font-mono text-xs text-gray-500", "{name_val}" } },
None => render! { "" } // No-op for None
},
match &v.description {
Some(desc_val) => render! { div { class: "font-light", "{desc_val}" } },
None => render! { "" } // No-op for None
}
}
} }
} }
@ -207,65 +166,46 @@ fn FlakeValView<'a>(cx: Scope, k: &'a String, v: &'a Val) -> impl IntoView {
/// ///
/// WARNING: This may cause performance problems if the tree is large. /// WARNING: This may cause performance problems if the tree is large.
#[component] #[component]
fn FlakeOutputsRawView<'a>(cx: Scope, outs: &'a FlakeOutputs) -> impl IntoView { pub fn FlakeOutputsRawView(cx: Scope, outs: FlakeOutputs) -> Element {
fn view_val<'b>(cx: Scope, val: &'b Val) -> View { #[component]
view! { cx, fn ValView<'a>(cx: Scope, val: &'a Val) -> Element {
<span> render! {
<b>{val.name.clone()}</b> span {
b { val.name.clone() }
" (" " ("
<TypeView type_=&val.type_/> TypeView { type_: &val.type_ }
") " ") "
<em>{val.description.clone()}</em> em { val.description.clone() }
</span> }
} }
.into_view(cx)
} }
#[component] #[component]
fn TypeView<'b>(cx: Scope, type_: &'b Type) -> impl IntoView { pub fn TypeView<'a>(cx: Scope, type_: &'a Type) -> Element {
view! { cx, render! {
<span> span {
{match type_ { match type_ {
Type::NixosModule => "nixosModule ❄️", Type::NixosModule => "nixosModule ❄️",
Type::Derivation => "derivation 📦", Type::Derivation => "derivation 📦",
Type::App => "app 📱", Type::App => "app 📱",
Type::Template => "template 🏗️", Type::Template => "template 🏗️",
Type::Unknown => "unknown ❓", Type::Unknown => "unknown ❓",
}} }
}
</span>
} }
} }
match outs { match outs {
FlakeOutputs::Val(v) => view_val(cx, v), FlakeOutputs::Val(v) => render! { ValView { val: v } },
FlakeOutputs::Attrset(v) => view! { cx, FlakeOutputs::Attrset(v) => render! {
<ul class="list-disc"> ul { class: "list-disc",
{v for (k , v) in v.iter() {
.iter() li { class: "ml-4",
.map(|(k, v)| { span { class: "px-2 py-1 font-bold text-primary-500", "{k}" }
view! { cx, FlakeOutputsRawView { outs: v.clone() }
<li class="ml-4"> }
<span class="px-2 py-1 font-bold text-primary-500">{k}</span> }
<FlakeOutputsRawView outs=v/> }
</li> },
}
})
.collect_view(cx)}
</ul>
}
.into_view(cx),
} }
} }
/// Get [Flake] info for the given flake url
#[server(GetFlake, "/api")]
pub async fn get_flake(args: (FlakeUrl, Refresh)) -> Result<Flake, ServerFnError> {
use nix_rs::command::NixCmd;
let (url, refresh) = args;
let nix_cmd = &NixCmd {
refresh,
..NixCmd::default()
};
let v = Flake::from_nix(nix_cmd, url).await?;
Ok(v)
}

View File

@ -1,100 +1,71 @@
//! Nix health check UI //! Nix health check UI
use leptos::*; use dioxus::prelude::*;
use leptos_extra::query::{self, RefetchQueryButton};
use leptos_meta::*;
use nix_health::traits::{Check, CheckResult}; use nix_health::traits::{Check, CheckResult};
use tracing::instrument;
use crate::widget::*; use crate::{app::state::AppState, app::widget::RefreshButton};
/// Nix health checks /// Nix health checks
#[component] pub fn Health(cx: Scope) -> Element {
pub fn NixHealthRoute(cx: Scope) -> impl IntoView { let state = AppState::use_state(cx);
let health_checks = state.health_checks.read();
let title = "Nix Health"; let title = "Nix Health";
let result = query::use_server_query(cx, || (), get_nix_health); render! {
let data = result.data; h1 { class: "text-5xl font-bold", title }
view! { cx, RefreshButton {
<Title text=title/> busy: (*health_checks).is_loading_or_refreshing(),
<h1 class="text-5xl font-bold">{title}</h1> handler: move |_event| {
<RefetchQueryButton result query=|| ()/> cx.spawn(async move {
<div class="my-1"> state.update_health_checks().await;
<SuspenseWithErrorHandling> });
<div class="flex flex-col items-stretch justify-start space-y-8 text-left"> }
<For }
each=move || data.get().unwrap_or(Ok(vec![])).unwrap_or(vec![]) health_checks.render_with(cx, |checks| render! {
key=|check| check.title.clone() div { class: "flex flex-col items-stretch justify-start space-y-8 text-left",
view=move |cx, check| { for check in checks {
view! { cx, <ViewCheck check/> } ViewCheck { check: check.clone() }
} }
/> }
})
</div>
</SuspenseWithErrorHandling>
</div>
} }
} }
#[component] #[component]
fn ViewCheck(cx: Scope, check: Check) -> impl IntoView { fn ViewCheck(cx: Scope, check: Check) -> Element {
view! { cx, render! {
<div class="contents"> div { class: "contents",
<details details {
open=check.result != CheckResult::Green open: check.result != CheckResult::Green,
class="my-2 bg-white border-2 rounded-lg cursor-pointer hover:bg-primary-100 border-base-300" class: "my-2 bg-white border-2 rounded-lg cursor-pointer hover:bg-primary-100 border-base-300",
> summary { class: "p-4 text-xl font-bold",
<summary class="p-4 text-xl font-bold"> CheckResultSummaryView { green: check.result.green() }
<CheckResultSummaryView green=check.result.green()/> " "
{" "} check.title.clone()
{check.title} }
</summary> div { class: "p-4",
<div class="p-4"> div { class: "p-2 my-2 font-mono text-sm bg-black text-base-100", check.info.clone() }
<div class="p-2 my-2 font-mono text-sm bg-black text-base-100"> div { class: "flex flex-col justify-start space-y-4",
{check.info} match check.result.clone() {
</div> CheckResult::Green => render! { "" },
<div class="flex flex-col justify-start space-y-4"> CheckResult::Red { msg, suggestion } => render! {
{match check.result { h3 { class: "my-2 font-bold text-l" }
CheckResult::Green => view! { cx, "" }.into_view(cx), div { class: "p-2 bg-red-400 rounded bg-border", msg }
CheckResult::Red { msg, suggestion } => { h3 { class: "my-2 font-bold text-l" }
view! { cx, div { class: "p-2 bg-blue-400 rounded bg-border", suggestion }
<h3 class="my-2 font-bold text-l"></h3>
<div class="p-2 bg-red-400 rounded bg-border">{msg}</div>
<h3 class="my-2 font-bold text-l"></h3>
<div class="p-2 bg-blue-400 rounded bg-border">
{suggestion}
</div>
}
.into_view(cx)
} }
}} }
}
</div> }
</div> }
</details> }
</div>
} }
} }
#[component] #[component]
pub fn CheckResultSummaryView(cx: Scope, green: bool) -> impl IntoView { pub fn CheckResultSummaryView(cx: Scope, green: bool) -> Element {
if green { if *green {
view! { cx, <span class="text-green-500">{""}</span> } render! { span { class: "text-green-500", "" } }
} else { } else {
view! { cx, <span class="text-red-500">{""}</span> } render! { span { class: "text-red-500", "" } }
} }
} }
/// Get [NixHealth] information
#[instrument(name = "nix-health")]
#[server(GetNixHealth, "/api")]
pub async fn get_nix_health(_unit: ()) -> Result<Vec<nix_health::traits::Check>, ServerFnError> {
use nix_health::NixHealth;
use nix_rs::{env, info};
let nix_info = info::NixInfo::from_nix(&nix_rs::command::NixCmd::default()).await?;
// TODO: Use Some(flake_url)? With what UX?
let nix_env = env::NixEnv::detect(None).await?;
let health = NixHealth::default();
let checks = health.run_checks(&nix_info, &nix_env);
Ok(checks)
}

View File

@ -2,128 +2,104 @@
use std::fmt::Display; use std::fmt::Display;
use leptos::*; use dioxus::prelude::*;
use leptos_extra::query::{self, RefetchQueryButton}; use nix_rs::{config::NixConfig, info::NixInfo, version::NixVersion};
use leptos_extra::signal::SignalWithResult;
use leptos_meta::*;
use nix_rs::{
config::{ConfigVal, NixConfig},
info::NixInfo,
version::NixVersion,
};
use crate::widget::*; use crate::{app::state::AppState, app::widget::RefreshButton};
/// Nix information /// Nix information
#[component] #[component]
pub fn NixInfoRoute(cx: Scope) -> impl IntoView { pub fn Info(cx: Scope) -> Element {
let title = "Nix Info"; let title = "Nix Info";
let result = query::use_server_query(cx, || (), get_nix_info); let state = AppState::use_state(cx);
let data = result.data; let nix_info = state.nix_info.read();
view! { cx, render! {
<Title text=title/> h1 { class: "text-5xl font-bold", title }
<h1 class="text-5xl font-bold">{title}</h1> RefreshButton {
<RefetchQueryButton result query=|| ()/> busy: (*nix_info).is_loading_or_refreshing(),
<div class="my-1 text-left"> handler: move |_event| {
<SuspenseWithErrorHandling> cx.spawn(async move {
{move || { state.update_nix_info().await;
data.with_result(move |info| { });
view! { cx, <NixInfoView info/> } }
}) }
}} nix_info.render_with(cx, |v| render! { NixInfoView { info: v.clone() } })
</SuspenseWithErrorHandling>
</div>
} }
} }
#[component] #[component]
fn NixInfoView<'a>(cx: Scope, info: &'a NixInfo) -> impl IntoView { fn NixInfoView(cx: Scope, info: NixInfo) -> Element {
view! { cx, render! {
<div class="flex flex-col p-4 space-y-8 bg-white border-2 rounded border-base-400"> div { class: "flex flex-col p-4 space-y-8 bg-white border-2 rounded border-base-400",
<div> div {
<b>Nix Version</b> b { "Nix Version" }
<div class="p-1 my-1 rounded bg-primary-50"> div { class: "p-1 my-1 rounded bg-primary-50", NixVersionView { version: info.nix_version } }
<NixVersionView version=&info.nix_version/> }
</div> div {
</div> b { "Nix Config" }
<div> NixConfigView { config: info.nix_config.clone() }
<b>Nix Config</b> }
<NixConfigView config=info.nix_config.clone()/>
</div>
</div>
}
}
#[component]
fn NixVersionView<'a>(cx: Scope, version: &'a NixVersion) -> impl IntoView {
view! { cx,
<a href=nix_rs::refs::RELEASE_HISTORY class="font-mono hover:underline" target="_blank">
{format!("{}", version)}
</a>
}
}
#[component]
fn NixConfigView(cx: Scope, config: NixConfig) -> impl IntoView {
#[component]
fn ConfigRow(cx: Scope, key: &'static str, title: String, children: Children) -> impl IntoView {
view! { cx,
// TODO: Use a nice Tailwind tooltip here, instead of "title"
// attribute.
<tr title=title>
<td class="px-4 py-2 font-semibold text-base-700">{key}</td>
<td class="px-4 py-2 text-left">
<code>{children(cx)}</code>
</td>
</tr>
} }
} }
view! { cx,
<div class="py-1 my-1 rounded bg-primary-50">
<table class="text-right">
// FIXME: so many clones
<tbody>
<ConfigRow key="Local System" title=config.system.description>
{config.system.value.to_string()}
</ConfigRow>
<ConfigRow key="Max Jobs" title=config.max_jobs.description.clone()>
{config.max_jobs.value}
</ConfigRow>
<ConfigRow key="Cores per build" title=config.cores.description>
{config.cores.value}
</ConfigRow>
<ConfigRow key="Nix Caches" title=config.substituters.clone().description>
<ConfigValListView cfg=config.substituters.clone()/>
</ConfigRow>
</tbody>
</table>
</div>
}
.into_view(cx)
} }
#[component] #[component]
pub fn ConfigValListView<T>(cx: Scope, cfg: ConfigVal<Vec<T>>) -> impl IntoView fn NixVersionView(cx: Scope, version: NixVersion) -> Element {
render! {a { href: nix_rs::refs::RELEASE_HISTORY, class: "font-mono hover:underline", target: "_blank", "{version}" }}
}
#[component]
fn NixConfigView(cx: Scope, config: NixConfig) -> Element {
let config_row = |key: &'static str, title: String, children: Element<'a>| {
render! {
tr { title: "{title}",
td { class: "px-4 py-2 font-semibold text-base-700", "{key}" }
td { class: "px-4 py-2 text-left",
code { children }
}
}
}
};
render! {
div { class: "py-1 my-1 rounded bg-primary-50",
table { class: "text-right",
tbody {
config_row (
"Local System",
config.system.description.clone(),
render! { "{config.system.value}" }
),
config_row (
"Max Jobs",
config.max_jobs.description.clone(),
render! {"{config.max_jobs.value}"}
),
config_row (
"Cores per build",
config.cores.description.clone(),
render! { "{config.cores.value}" }
),
config_row (
"Nix Caches",
config.substituters.clone().description,
render! { ConfigValList { items: &config.substituters.value } }
)
}
}
}
}
}
#[component]
fn ConfigValList<T, 'a>(cx: Scope, items: &'a Vec<T>) -> Element
where where
T: Display, T: Display,
{ {
view! { cx, render! {
// Render a list of T items in the list 'self' div { class: "flex flex-col space-y-4",
<div class="flex flex-col space-y-4"> for item in items {
{cfg li { class: "list-disc", "{item}" }
.value }
.into_iter() }
.map(|item| view! { cx, <li class="list-disc">{item.to_string()}</li> })
.collect_view(cx)}
</div>
} }
.into_view(cx)
}
/// Determine [NixInfo] on the user's system
#[server(GetNixInfo, "/api")]
pub async fn get_nix_info(_unit: ()) -> Result<NixInfo, ServerFnError> {
let v = NixInfo::from_nix(&nix_rs::command::NixCmd::default()).await?;
Ok(v)
} }

View File

@ -6,141 +6,139 @@
mod flake; mod flake;
mod health; mod health;
mod info; mod info;
mod state;
mod widget;
use leptos::*; use dioxus::prelude::*;
use leptos_extra::{ use dioxus_router::prelude::*;
query::{self}, use nix_rs::flake::url::FlakeUrl;
signal::{provide_signal, SignalWithResult},
use crate::app::{
flake::{Flake, FlakeRaw},
health::Health,
info::Info,
state::AppState,
widget::Loader,
}; };
use leptos_meta::*;
use leptos_query::*;
use leptos_router::*;
use nix_rs::{command::Refresh, flake::url::FlakeUrl};
use crate::{app::flake::*, app::health::*, app::info::*, widget::*}; #[derive(Routable, PartialEq, Debug, Clone)]
#[rustfmt::skip]
enum Route {
#[layout(Wrapper)]
#[route("/")]
Dashboard {},
#[route("/about")]
About {},
#[route("/flake")]
Flake {},
#[route("/flake/raw")]
FlakeRaw {},
#[route("/health")]
Health {},
#[route("/info")]
Info {},
}
fn Wrapper(cx: Scope) -> Element {
render! {
Nav {}
Outlet::<Route> {}
footer { class: "flex flex-row justify-center w-full p-4", img { src: "images/128x128.png", width: "32", height: "32" } }
}
}
/// Main frontend application container /// Main frontend application container
#[component] pub fn App(cx: Scope) -> Element {
pub fn App(cx: Scope) -> impl IntoView { AppState::provide_state(cx);
provide_meta_context(cx); use_shared_state_provider(cx, || {
provide_query_client(cx);
provide_signal::<FlakeUrl>(
cx,
FlakeUrl::suggestions() FlakeUrl::suggestions()
.first() .first()
.map(Clone::clone) .map(Clone::clone)
.unwrap_or_default(), .unwrap_or_default()
); });
provide_signal::<Refresh>(cx, false.into()); // refresh flag is unused, but we may add it to UI later. render! {
body {
// Can't do this, because Tauri window has its own scrollbar. :-/
// class: "overflow-y-scroll",
div { class: "flex justify-center w-full min-h-screen bg-center bg-cover bg-base-200",
div { class: "flex flex-col items-stretch mx-auto sm:container sm:max-w-screen-md",
main { class: "flex flex-col px-2 mb-8 space-y-3 text-center", Router::<Route> {} }
}
}
}
}
}
view! { cx, // Home page
<Stylesheet id="leptos" href="/pkg/nix-browser.css"/> fn Dashboard(cx: Scope) -> Element {
<Title formatter=|s| format!("{s} ― nix-browser")/> tracing::debug!("Rendering Dashboard page");
<Router fallback=|cx| { let state = AppState::use_state(cx);
view! { cx, <NotFound/> } let health_checks = state.health_checks.read();
}> // A Card component
<Body class="overflow-y-scroll"/> #[component]
<div class="flex justify-center w-full min-h-screen bg-center bg-cover bg-base-200"> fn Card<'a>(cx: Scope, href: Route, children: Element<'a>) -> Element<'a> {
<div class="flex flex-col items-stretch mx-auto sm:container sm:max-w-screen-md"> render! {
<Nav/> Link {
<main class="flex flex-col px-2 mb-8 space-y-3 text-center"> to: "{href}",
<Routes> class: "flex items-center justify-center w-48 h-48 p-2 m-2 border-2 rounded-lg shadow border-base-400 active:shadow-none bg-base-100 hover:bg-primary-200",
<Route path="" view=Dashboard/> span { class: "text-3xl text-base-800", children }
<Route path="/flake" view=NixFlakeRoute> }
<Route path="" view=NixFlakeHomeRoute/> }
<Route path="raw" view=NixFlakeRawRoute/> }
</Route> render! {
<Route path="/health" view=NixHealthRoute/> h1 { class: "text-5xl font-bold", "Dashboard" }
<Route path="/info" view=NixInfoRoute/> div { id: "cards", class: "flex flex-row flex-wrap",
<Route path="/about" view=About/> Card { href: Route::Health {},
</Routes> "Nix Health Check "
</main> match (*health_checks).current_value() {
</div> Some(Ok(checks)) => render! {
</div> if checks.iter().all(|check| check.result.green()) {
</Router> ""
} else {
""
}
},
Some(Err(err)) => render! { "{err}" },
None => render! { Loader {} },
}
}
Card { href: Route::Info {}, "Nix Info " }
Card { href: Route::Flake {}, "Flake Dashboard ❄️️" }
}
} }
} }
/// Navigation bar /// Navigation bar
/// ///
/// TODO Switch to breadcrumbs, as it simplifes the design overall. /// TODO Switch to breadcrumbs, as it simplifes the design overall.
#[component] fn Nav(cx: Scope) -> Element {
fn Nav(cx: Scope) -> impl IntoView {
let class = "px-3 py-2"; let class = "px-3 py-2";
view! { cx, let active_class = "bg-white text-primary-800 font-bold";
<nav class="flex flex-row w-full mb-8 text-white md:rounded-b bg-primary-800"> render! {
<A exact=true href="/" class=class> nav { class: "flex flex-row w-full mb-8 text-white md:rounded-b bg-primary-800",
"Dashboard" Link { to: Route::Dashboard {}, class: class, active_class: active_class, "Dashboard" }
</A> Link { to: Route::Flake {}, class: class, active_class: active_class, "Flake" }
<A exact=false href="/flake" class=class> Link { to: Route::Health {}, class: class, active_class: active_class, "Nix Health" }
"Flake" Link { to: Route::Info {}, class: class, active_class: active_class, "Nix Info" }
</A> Link { to: Route::About {}, class: class, active_class: active_class, "About" }
<A exact=true href="/health" class=class> div { class: "flex-grow font-bold text-end {class}", "🌍 nix-browser" }
"Nix Health"
</A>
<A exact=true href="/info" class=class>
"Nix Info"
</A>
<A exact=true href="/about" class=class>
"About"
</A>
<div class=format!("flex-grow font-bold text-end {}", class)>"🌍 nix-browser"</div>
</nav>
}
}
/// Home page
#[component]
fn Dashboard(cx: Scope) -> impl IntoView {
tracing::debug!("Rendering Dashboard page");
let result = query::use_server_query(cx, || (), get_nix_health);
let data = result.data;
let healthy = Signal::derive(cx, move || {
data.with_result(|checks| checks.iter().all(|check| check.result.green()))
});
// A Card component
#[component]
fn Card(cx: Scope, href: &'static str, children: Children) -> impl IntoView {
view! { cx,
<A
href=href
class="flex items-center justify-center w-48 h-48 p-2 m-2 border-2 rounded-lg shadow border-base-400 active:shadow-none bg-base-100 hover:bg-primary-200"
>
<span class="text-3xl text-base-800">{children(cx)}</span>
</A>
} }
} }
view! { cx,
<Title text="Dashboard"/>
<h1 class="text-5xl font-bold">"Dashboard"</h1>
<div id="cards" class="flex flex-row flex-wrap">
<SuspenseWithErrorHandling>
<Card href="/health">
"Nix Health Check "
{move || {
healthy
.with_result(move |green| {
view! { cx, <CheckResultSummaryView green=*green/> }
})
}}
</Card>
</SuspenseWithErrorHandling>
<Card href="/info">"Nix Info "</Card>
<Card href="/flake">"Flake Overview ❄️️"</Card>
</div>
}
} }
/// About page /// About page
#[component] fn About(cx: Scope) -> Element {
fn About(cx: Scope) -> impl IntoView { render! {
view! { cx, h1 { class: "text-5xl font-bold", "About" }
<Title text="About"/> p {
<h1 class="text-5xl font-bold">"About"</h1>
<p>
"nix-browser is still work in progress. Track its development " "nix-browser is still work in progress. Track its development "
<LinkExternal link="https://github.com/juspay/nix-browser" text="on Github"/> a {
</p> href: "https://github.com/juspay/nix-browser",
class: "underline text-primary-500 hover:no-underline",
rel: "external",
target: "_blank",
"on Github"
}
}
} }
} }

161
src/app/state.rs Normal file
View File

@ -0,0 +1,161 @@
//! Application state
mod datum;
use std::fmt::Display;
use dioxus::prelude::{use_context, use_context_provider, use_future, Scope};
use dioxus_signals::Signal;
use nix_health::NixHealth;
use nix_rs::{
command::NixCmdError,
flake::{url::FlakeUrl, Flake},
};
use tracing::instrument;
use self::datum::Datum;
/// Our dioxus application state is a struct of [Signal]
///
/// They use [Datum] which is a glorified [Option] to distinguis between initial
/// loading and subsequent refreshing.
#[derive(Default, Clone, Copy, Debug)]
pub struct AppState {
pub nix_info: Signal<Datum<Result<nix_rs::info::NixInfo, SystemError>>>,
pub nix_env: Signal<Datum<Result<nix_rs::env::NixEnv, SystemError>>>,
pub health_checks: Signal<Datum<Result<Vec<nix_health::traits::Check>, SystemError>>>,
pub flake_url: Signal<FlakeUrl>,
pub flake: Signal<Datum<Result<Flake, NixCmdError>>>,
}
impl AppState {
pub async fn initialize(&self) {
tracing::info!("Initializing app state");
// Initializing health checks automatially initializes other signals.
self.update_health_checks().await;
}
#[instrument(name = "update-nix-info", skip(self))]
pub async fn update_nix_info(&self) {
tracing::debug!("Updating nix info ...");
Datum::refresh_with(self.nix_info, async {
// NOTE: Without tokio::spawn, this will run in main desktop thread,
// and will hang at some point.
let nix_info = tokio::spawn(async move {
nix_rs::info::NixInfo::from_nix(&nix_rs::command::NixCmd::default())
.await
.map_err(|e| SystemError {
message: format!("Error getting nix info: {:?}", e),
})
})
.await
.unwrap();
tracing::debug!("Got nix info, about to mut");
nix_info
})
.await;
}
#[instrument(name = "update-nix-env", skip(self))]
pub async fn update_nix_env(&self) {
tracing::debug!("Updating nix env ...");
Datum::refresh_with(self.nix_env, async {
let nix_env = tokio::spawn(async move {
nix_rs::env::NixEnv::detect(None)
.await
.map_err(|e| e.to_string().into())
})
.await
.unwrap();
tracing::debug!("Got nix env, about to mut");
nix_env
})
.await;
}
#[instrument(name = "update-health-checks", skip(self))]
pub async fn update_health_checks(&self) {
tracing::debug!("Updating health checks ...");
Datum::refresh_with(self.health_checks, async {
// Update depenencies
self.update_nix_info().await;
self.update_nix_env().await;
let get_nix_health = move || -> Result<Vec<nix_health::traits::Check>, SystemError> {
let nix_env = self.nix_env.read();
let nix_env: &nix_rs::env::NixEnv = nix_env
.current_value()
.unwrap()
.as_ref()
.map_err(|e| Into::<SystemError>::into(e.to_string()))?;
let nix_info = self.nix_info.read();
let nix_info: &nix_rs::info::NixInfo =
nix_info
.current_value()
.unwrap()
.as_ref()
.map_err(|e| Into::<SystemError>::into(e.to_string()))?;
let health_checks = NixHealth::default().run_checks(nix_info, nix_env);
Ok(health_checks)
};
let health_checks = get_nix_health();
tracing::debug!("Got health checks, about to mut");
health_checks
})
.await;
}
#[instrument(name = "set-flake-url", skip(self))]
pub async fn set_flake_url(&self, url: &FlakeUrl) {
// TODO: Can we use derived signals here?
self.flake_url.set(url.clone());
self.update_flake().await;
}
#[instrument(name = "update-flake", skip(self))]
pub async fn update_flake(&self) {
tracing::debug!("Updating flake ...");
Datum::refresh_with(self.flake, async {
let flake_url = self.flake_url.read().clone();
let flake = tokio::spawn(async move {
Flake::from_nix(&nix_rs::command::NixCmd::default(), flake_url.clone()).await
})
.await
.unwrap();
tracing::debug!("Got flake, about to mut");
flake
})
.await;
}
/// Get the [AppState] from context
pub fn use_state(cx: Scope) -> Self {
*use_context(cx).unwrap()
}
pub fn provide_state(cx: Scope) {
use_context_provider(cx, AppState::default);
let state = AppState::use_state(cx);
use_future(cx, (), |_| async move {
state.initialize().await;
});
}
}
/// Catch all error to use in UI components
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SystemError {
pub message: String,
}
impl Display for SystemError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl From<String> for SystemError {
fn from(message: String) -> Self {
Self { message }
}
}

109
src/app/state/datum.rs Normal file
View File

@ -0,0 +1,109 @@
use std::{fmt::Display, future::Future};
use dioxus::prelude::*;
use dioxus_signals::Signal;
/// Represent loading/refreshing state of UI data
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Datum<T> {
#[default]
Loading,
Available {
value: T,
refreshing: bool,
},
}
impl<T> Datum<T> {
pub fn is_loading_or_refreshing(&self) -> bool {
matches!(
self,
Datum::Loading
| Datum::Available {
value: _,
refreshing: true
}
)
}
/// Get the inner value if available
pub fn current_value(&self) -> Option<&T> {
match self {
Datum::Loading => None,
Datum::Available {
value: x,
refreshing: _,
} => Some(x),
}
}
/// Set the datum value
///
/// Use [refresh_with] if the value is produced by a long-running task.
fn set_value(&mut self, value: T) {
tracing::debug!("🍒 Setting {} datum value", std::any::type_name::<T>());
*self = Datum::Available {
value,
refreshing: false,
}
}
/// Mark the datum is being-refreshed
///
/// Do this just prior to doing a long-running task that will provide a
/// value to be set using [set_value]
fn mark_refreshing(&mut self) {
if let Datum::Available {
value: _,
refreshing,
} = self
{
if *refreshing {
panic!("Cannot refresh already refreshing data");
}
tracing::debug!(
"🍒 Marking {} datum as refreshing",
std::any::type_name::<T>()
);
*refreshing = true;
}
}
/// Refresh the datum [Signal] using the given function
///
/// Refresh state is automatically set.
pub async fn refresh_with<F>(signal: Signal<Self>, f: F)
where
F: Future<Output = T>,
{
signal.with_mut(move |x| {
x.mark_refreshing();
});
let val = f.await;
signal.with_mut(move |x| {
x.set_value(val);
});
}
}
impl<T, E: Display> Datum<Result<T, E>> {
/// Render the result datum with the given component
///
/// The error message will be rendered appropriately. If the datum is
/// unavailable, nothing will be rendered (loading state is rendered
/// differently)
pub fn render_with<'a, F>(&self, cx: &'a Scoped<'a, ()>, component: F) -> Element<'a>
where
F: FnOnce(&T) -> Element<'a>,
{
match self.current_value()? {
Ok(value) => component(value),
Err(err) => render! {
div {
class: "p-4 my-1 text-left text-sm font-mono text-white bg-red-500 rounded",
"Error: {err}"
}
},
}
}
}

45
src/app/widget.rs Normal file
View File

@ -0,0 +1,45 @@
//! Various widgets
use dioxus::prelude::*;
/// A refresh button with a busy indicator
///
/// You want to use [crate::state::datum] for this.
#[component]
pub fn RefreshButton<F>(cx: Scope, busy: bool, handler: F) -> Element
where
F: Fn(Event<MouseData>),
{
let button_cls = if *busy {
"bg-gray-400 text-white"
} else {
"bg-blue-700 text-white hover:bg-blue-800"
};
render! {
div { class: "flex-col items-center justify-center space-y-2 mb-4",
button {
class: "py-1 px-2 shadow-lg border-1 {button_cls} rounded-md",
disabled: *busy,
onclick: handler,
"Refresh "
if *busy {
render! { "" }
} else {
render! { "🔄" }
}
}
if *busy {
render! { Loader {} }
}
}
}
}
#[component]
pub fn Loader(cx: Scope) -> Element {
render! {
div { class: "flex justify-center items-center",
div { class: "animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-purple-500" }
}
}
}

View File

@ -1,31 +1,10 @@
//! Command-line interface //! Command-line interface
use clap::Parser; use clap::Parser;
use std::net::SocketAddr;
use crate::logging; use crate::logging;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub struct Args { pub struct Args {
/// Do not automatically open the application in the local browser
///
/// Enabled by default if the app is running under `cargo leptos ...`
#[arg(short = 'n', long = "no-open", env = "NIX_BROWSER_NO_OPEN")]
pub no_open: bool,
/// The address to serve the application on
///
/// Format: `IP_ADDRESS:PORT`
///
/// Uses localhost and random port by default. To use a different port, pass
/// `127.0.0.1:8080`
#[arg(
short = 's',
long = "site-addr",
default_value = "127.0.0.1:0",
env = "LEPTOS_SITE_ADDR"
)]
pub site_addr: Option<SocketAddr>,
#[command(flatten)] #[command(flatten)]
pub verbosity: logging::Verbosity, pub verbosity: logging::Verbosity,
} }

View File

@ -1,27 +0,0 @@
#![feature(associated_type_defaults)]
//! nix-browser crate; see GitHub [README] for details.
//!
//! [README]: https://github.com/juspay/nix-browser
pub mod app;
pub mod logging;
pub mod widget;
#[cfg(feature = "hydrate")]
use wasm_bindgen::prelude::wasm_bindgen;
#[cfg(feature = "ssr")]
pub mod cli;
#[cfg(feature = "ssr")]
pub mod server;
/// Main entry point for the WASM frontend
#[cfg(feature = "hydrate")]
#[wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
use leptos::*;
logging::setup_client_logging();
tracing::info!("Hydrating app");
leptos::mount_to_body(move |cx| {
view! { cx, <App/> }
});
}

View File

@ -1,52 +1,15 @@
//! Logging setup for the server and client //! Logging setup for the server and client
#[cfg(feature = "ssr")]
use tower_http::{
classify::{ServerErrorsAsFailures, SharedClassifier},
trace::TraceLayer,
};
#[cfg(feature = "ssr")]
use tracing_subscriber::filter::{Directive, LevelFilter}; use tracing_subscriber::filter::{Directive, LevelFilter};
#[cfg(feature = "ssr")]
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
/// Setup server-side logging using [tracing_subscriber] pub fn setup_logging(verbosity: &Verbosity) {
#[cfg(feature = "ssr")]
pub fn setup_server_logging(verbosity: &Verbosity) {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(verbosity.log_filter()) .with_env_filter(verbosity.log_filter())
.compact() .compact()
.init(); .init();
} }
/// Setup browser console logging using [tracing_subscriber_wasm]
#[cfg(feature = "hydrate")]
pub fn setup_client_logging() {
tracing_subscriber::fmt()
.with_writer(
// To avoide trace events in the browser from showing their
// JS backtrace, which is very annoying, in my opinion
tracing_subscriber_wasm::MakeConsoleWriter::default()
.map_trace_level_to(tracing::Level::DEBUG),
)
.with_max_level(tracing::Level::INFO)
// For some reason, if we don't do this in the browser, we get
// a runtime error.
.without_time()
.init();
}
/// Setup HTTP request logging
#[cfg(feature = "ssr")]
pub fn http_trace_layer() -> TraceLayer<SharedClassifier<ServerErrorsAsFailures>> {
use tower_http::trace;
use tracing::Level;
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(trace::DefaultOnResponse::new().level(Level::INFO))
}
#[derive(clap::Args, Debug, Clone)] #[derive(clap::Args, Debug, Clone)]
pub struct Verbosity { pub struct Verbosity {
/// Server logging level /// Server logging level
@ -56,7 +19,6 @@ pub struct Verbosity {
pub verbose: u8, pub verbose: u8,
} }
#[cfg(feature = "ssr")]
impl Verbosity { impl Verbosity {
/// Return the log filter for CLI flag. /// Return the log filter for CLI flag.
fn log_filter(&self) -> EnvFilter { fn log_filter(&self) -> EnvFilter {
@ -74,25 +36,18 @@ impl Verbosity {
LevelFilter::WARN.into(), LevelFilter::WARN.into(),
"nix_browser=info".parse().unwrap(), "nix_browser=info".parse().unwrap(),
"nix_rs=info".parse().unwrap(), "nix_rs=info".parse().unwrap(),
"leptos_extra=info".parse().unwrap(),
], ],
// -v: log app DEBUG level, as well as http requests // -v: log app DEBUG level, as well as http requests
1 => vec![ 1 => vec![
LevelFilter::WARN.into(), LevelFilter::WARN.into(),
"nix_browser=debug".parse().unwrap(), "nix_browser=debug".parse().unwrap(),
"nix_rs=debug".parse().unwrap(), "nix_rs=debug".parse().unwrap(),
"leptos_extra=debug".parse().unwrap(),
// 3rd-party libraries
"tower_http=info".parse().unwrap(),
], ],
// -vv: log app TRACE level, as well as http requests // -vv: log app TRACE level, as well as http requests
2 => vec![ 2 => vec![
LevelFilter::WARN.into(), LevelFilter::WARN.into(),
"nix_browser=trace".parse().unwrap(), "nix_browser=trace".parse().unwrap(),
"nix_rs=trace".parse().unwrap(), "nix_rs=trace".parse().unwrap(),
"leptos_extra=trace".parse().unwrap(),
// 3rd-party libraries
"tower_http=info".parse().unwrap(),
], ],
// -vvv: log DEBUG level of app and libraries // -vvv: log DEBUG level of app and libraries
3 => vec![LevelFilter::DEBUG.into()], 3 => vec![LevelFilter::DEBUG.into()],

View File

@ -1,13 +1,23 @@
#[cfg(feature = "ssr")] use dioxus_desktop::{LogicalSize, WindowBuilder};
mod app;
mod cli;
mod logging;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
use clap::Parser; use clap::Parser;
human_panic::setup_panic!(); let args = crate::cli::Args::parse();
let args = nix_browser::cli::Args::parse(); crate::logging::setup_logging(&args.verbosity);
nix_browser::server::main(args).await
}
#[cfg(not(feature = "ssr"))] dioxus_desktop::launch_cfg(
fn main() { app::App,
// No main entry point for wasm dioxus_desktop::Config::new()
.with_custom_head(r#" <link rel="stylesheet" href="tailwind.css"> "#.to_string())
.with_window(
WindowBuilder::new()
.with_title("nix-browser")
.with_inner_size(LogicalSize::new(900, 600)),
),
)
} }

View File

@ -1,87 +0,0 @@
//! Axum server
use std::convert::Infallible;
use crate::app::App;
use axum::response::Response as AxumResponse;
use axum::routing::IntoMakeService;
use axum::{body::Body, http::Request, response::IntoResponse};
use axum::{routing::post, Router};
use hyper::server::conn::AddrIncoming;
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use std::net::SocketAddr;
use tower_http::services::ServeDir;
use tracing::instrument;
use crate::cli;
/// Axum server main entry point
pub async fn main(args: cli::Args) {
crate::logging::setup_server_logging(&args.verbosity);
let leptos_options = get_leptos_options(&args).await;
let server = create_server(leptos_options).await;
if !args.no_open {
open_http_app(server.local_addr()).await;
}
server.await.unwrap()
}
/// Create an Axum server for the Leptos app
#[instrument(name = "server")]
#[allow(clippy::async_yields_async)]
async fn create_server(
leptos_options: leptos_config::LeptosOptions,
) -> axum::Server<AddrIncoming, IntoMakeService<axum::Router>> {
tracing::debug!("Firing up Leptos app with config: {:?}", leptos_options);
leptos_query::suppress_query_load(true); // https://github.com/nicoburniske/leptos_query/issues/6
let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
leptos_query::suppress_query_load(false);
let client_dist = ServeDir::new(leptos_options.site_root.clone());
let leptos_options_clone = leptos_options.clone(); // A copy to move to the closure below.
let not_found_service =
tower::service_fn(move |req| not_found_handler(leptos_options_clone.to_owned(), req));
let app = Router::new()
// server functions API routes
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
// application routes
.leptos_routes(&leptos_options, routes, |cx| view! { cx, <App/> })
// static files are served as fallback (but *before* falling back to
// error handler)
.fallback_service(client_dist.clone().not_found_service(not_found_service))
// enable HTTP request logging
.layer(crate::logging::http_trace_layer())
.with_state(leptos_options.clone());
let server = axum::Server::bind(&leptos_options.site_addr).serve(app.into_make_service());
tracing::info!("nix-browser web 🌀️ http://{}", server.local_addr());
server
}
async fn get_leptos_options(args: &cli::Args) -> leptos_config::LeptosOptions {
let conf_file = get_configuration(None).await.unwrap();
leptos_config::LeptosOptions {
site_addr: args.site_addr.unwrap_or(conf_file.leptos_options.site_addr),
..conf_file.leptos_options
}
}
/// Handler for missing routes
///
/// On missing routes, just delegate to the leptos app, which has a route
/// fallback rendering 404 response.
async fn not_found_handler(
options: LeptosOptions,
req: Request<Body>,
) -> Result<AxumResponse, Infallible> {
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), move |cx| view! { cx, <App/> });
Ok(handler(req).await.into_response())
}
/// Open a http address in the user's web browser
async fn open_http_app(addr: SocketAddr) {
let url = format!("http://{}", &addr);
if let Err(err) = open::that(url) {
tracing::warn!("Unable to open in web browser: {}", err)
}
}

View File

@ -1,176 +0,0 @@
//! Various Leptos widgets
use std::{fmt::Display, hash::Hash, str::FromStr};
use cfg_if::cfg_if;
#[cfg(feature = "ssr")]
use http::status::StatusCode;
use leptos::*;
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
use leptos_router::*;
// A loading spinner
#[component]
pub fn Spinner(cx: Scope) -> impl IntoView {
view! { cx,
<div
class="animate-spin inline-block w-6 h-6 border-[3px] border-current border-t-transparent text-blue-600 rounded-full"
role="status"
aria-label="loading"
>
<span class="sr-only">"Loading..."</span>
</div>
}
}
/// A `<a>` link
#[component]
pub fn Link(cx: Scope, link: &'static str, text: &'static str) -> impl IntoView {
view! { cx,
<A href=link class="text-primary-100 hover:no-underline">
{text}
</A>
}
}
/// A `<a>` link that links to an external site
#[component]
pub fn LinkExternal(cx: Scope, link: &'static str, text: &'static str) -> impl IntoView {
view! { cx,
<a
href=link
class="underline text-primary-500 hover:no-underline"
rel="external"
target="_blank"
>
{text}
</a>
}
}
/// 404 page
#[component]
pub fn NotFound(cx: Scope) -> impl IntoView {
cfg_if! { if #[cfg(feature="ssr")] {
if let Some(response) = use_context::<ResponseOptions>(cx) {
response.set_status(StatusCode::NOT_FOUND);
}
}}
view! { cx,
// The HTML for 404 not found
<div class="grid w-full min-h-screen bg-center bg-cover bg-base-100 place-items-center">
<div class="z-0 flex items-center justify-center col-start-1 row-start-1 text-center">
<div class="flex flex-col space-y-3">
<h1 class="text-5xl font-bold">"404"</h1>
<p class="py-6">
<h2 class="text-3xl font-bold text-gray-500">"Page not found"</h2>
<p class="my-1">"The page you are looking for does not exist."</p>
</p>
<Link link="/" text="Go to home page"/>
</div>
</div>
</div>
}
}
/// Display errors to the user
#[component]
pub fn Errors(cx: Scope, errors: Errors) -> impl IntoView {
tracing::error!("Errors: {:?}", errors);
view! { cx,
<div class="flex flex-col justify-center overflow-auto">
<header class="p-2 text-xl font-bold text-white bg-error-500">"💣 ERROR 💣"</header>
<div class="p-2 font-mono text-sm text-left whitespace-pre-wrap bg-black">
<ul>
{errors
.into_iter()
.map(|(k, e)| {
view! { cx,
<li class="mb-4">
<header class="px-2 mb-2 font-bold text-gray-100">
{format!("{:?}", k)}
</header>
<div class="px-2 text-gray-400 hover:text-gray-100">
{e.to_string()}
</div>
</li>
}
})
.collect_view(cx)}
</ul>
</div>
</div>
}
}
/// Like [Suspense] but also handles errors using [ErrorBoundary]
#[component(transparent)]
pub fn SuspenseWithErrorHandling(cx: Scope, children: ChildrenFn) -> impl IntoView {
let children = store_value(cx, children);
view! { cx,
<Suspense fallback=move || view! { cx, <Spinner/> }>
<ErrorBoundary fallback=|cx, errors| {
view! { cx, <Errors errors=errors.get()/> }
}>{children.with_value(|c| c(cx))}</ErrorBoundary>
</Suspense>
}
}
/// An input element component with suggestions.
///
/// A label, input element, and datalist are rendered, as well as error div.
/// [FromStr::from_str] is used to parse the input value into `K`.
///
/// Arguments:
/// * `id`: The id of the input element
/// * `label`: The label string
/// * `suggestions`: The initial suggestions to show in the datalist
/// * `val`: The [RwSignal] mirror'ing the input element value
#[component]
pub fn TextInput<K>(
cx: Scope,
id: &'static str,
label: &'static str,
/// Initial suggestions to show in the datalist
suggestions: Vec<K>,
val: RwSignal<K>,
) -> impl IntoView
where
K: ToString + FromStr + Hash + Eq + Clone + Display + 'static,
<K as std::str::FromStr>::Err: Display,
{
let datalist_id = &format!("{}-datalist", id);
// Input query to the server fn
// Errors in input element (based on [FromStr::from_str])
let (input_err, set_input_err) = create_signal(cx, None::<String>);
view! { cx,
<label for=id>{label}</label>
<input
list=datalist_id
id=id.to_string()
type="text"
class="w-full p-1 font-mono"
on:change=move |ev| {
match FromStr::from_str(&event_target_value(&ev)) {
Ok(s) => {
val.set(s);
set_input_err(None)
}
Err(e) => set_input_err(Some(e.to_string())),
}
}
prop:value=move || val.get().to_string()
/>
<span class="text-red-500">{input_err}</span>
// TODO: use local storage, and cache user's inputs
<datalist id=datalist_id>
{suggestions
.iter()
.map(|s| view! { cx, <option value=s.to_string()></option> })
.collect_view(cx)}
</datalist>
}
}

View File

@ -4,7 +4,7 @@ const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = { module.exports = {
content: [ content: [
"./src/**/*.rs", "./src/**/*.rs",
"./crates/leptos_extra/**/*.rs" "./dist/**/*.html"
], ],
theme: { theme: {
fontFamily: { fontFamily: {