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
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",
"*.rs": "html"
},
"tailwindCSS.experimental.classRegex": [
"class: \"(.*)\""
],
"files.associations": {
"*.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"
clap = { version = "4.3", features = ["derive", "env"] }
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-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_health = { path = "./crates/nix_health" }
leptos_extra = { path = "./crates/leptos_extra" }
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_with = { version = "3.2", features = ["json"] }
bytesize = { version = "1.3.0", features = ["serde"] }
anyhow = "1.0.75"
[package]
edition = "2021"
@ -37,89 +31,29 @@ homepage = "https://github.com/juspay/nix-browser"
[package.metadata.docs.rs]
all-features = true
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.6", features = ["json", "tokio"], optional = true }
axum-macros = { version = "0.3", optional = true }
anyhow.workspace = true
cfg-if.workspace = true
clap.workspace = true
console_error_panic_hook = "0.1"
console_log = { version = "1" }
http = { version = "0.2", optional = true }
console_log = "1"
http = "0.2"
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"
open = { version = "5.0", optional = true }
serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
thiserror.workspace = true
tokio = { version = "1.29", features = ["full"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.4", features = ["full"], optional = true }
tokio = { version = "1", features = ["full"] }
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-subscriber-wasm.workspace = true
uuid = { version = "1.3.0", features = ["serde", "v4", "js"] }
wasm-bindgen.workspace = true
nix_rs.workspace = true
nix_health.workspace = true
leptos_extra.workspace = true
[features]
default = [
"ssr",
] # 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"
dioxus = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
dioxus-desktop = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
dioxus-router = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
dioxus-signals = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "459f24d5e9ef0e03a3bbd037342c60bbde409dbf" }
dioxus-std = { version = "0.4.0", features = ["clipboard", "utils"] }

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
🚧 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
@ -15,12 +15,17 @@ This will automatically activate the nix develop shell. Open VSCode and install
## 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
# In another,
just tw
```
`just watch` runs `dx serve` (with hot reload disabled) that will restart the desktop app after compilation.
## 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:
@ -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.
- Add tests if relevant, and run them:
- 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`.
## 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

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]]
name = "nix-health"
path = "src/main.rs"
required-features = ["ssr"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cfg-if.workspace = true
clap = { workspace = true, optional = true }
clap = { workspace = true }
regex = "1.9.3"
thiserror.workspace = true
serde.workspace = true
serde_json.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"] }
nix_rs.workspace = true
human-panic.workspace = true
anyhow = { version = "1.0.75", optional = true }
colored = { version = "2.0", optional = true }
which = { version = "4.4.2", optional = true }
anyhow = { version = "1.0.75" }
colored = { version = "2.0" }
which = { version = "4.4.2" }
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 serde::{Deserialize, Serialize};
use url::Url;
#[cfg(feature = "ssr")]
use crate::traits::*;
/// 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 {
fn check(&self, nix_info: &info::NixInfo, nix_env: &env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_config.substituters.value;

View File

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

View File

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

View File

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

View File

@ -1,9 +1,8 @@
use nix_rs::version::NixVersion;
#[cfg(feature = "ssr")]
use nix_rs::{env, info};
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*;
/// 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 {
fn check(&self, nix_info: &info::NixInfo, _nix_env: &env::NixEnv) -> Vec<Check> {
let val = &nix_info.nix_version;

View File

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

View File

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

View File

@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
use crate::traits::*;
/// Check that [crate::nix::config::NixConfig::trusted_users] is set to a good value.
@ -8,7 +7,6 @@ use crate::traits::*;
#[serde(default)]
pub struct TrustedUsers {}
#[cfg(feature = "ssr")]
impl Checkable for TrustedUsers {
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;

View File

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

View File

@ -1,12 +1,13 @@
use serde::{Deserialize, Serialize};
/// Types that can do specific "health check" for Nix
#[cfg(feature = "ssr")]
pub trait Checkable {
/// Run and create the health check
///
/// NOTE: Some checks may perform impure actions (IO, etc.). Returning an
/// 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>;
}

View File

@ -20,14 +20,11 @@ thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
tokio = { version = "1.29", features = ["full"], optional = true }
tokio = { version = "1.29", features = ["full"] }
tracing.workspace = true
url = { version = "2.4", features = ["serde"] }
colored = { version = "2.0", optional = true }
shell-words = { version = "1.1.0", optional = true }
is_proc_translated = { version = "0.1.1", optional = true }
colored = { version = "2.0" }
shell-words = { version = "1.1.0" }
is_proc_translated = { version = "0.1.1" }
sysinfo.version = "0.29.10"
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 thiserror::Error;
#[cfg(feature = "ssr")]
use tokio::process::Command;
#[cfg(feature = "ssr")]
use tracing::instrument;
/// 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
/// rest of the instrumentation parameters.
#[cfg(feature = "ssr")]
#[instrument(name = "command")]
pub fn trace_cmd(cmd: &tokio::process::Command) {
use colored::Colorize;
tracing::info!("🐚 {}", to_cli(cmd).bright_blue());
}
#[cfg(feature = "ssr")]
impl NixCmd {
/// Return a [Command] for this [NixCmd] configuration
pub fn command(&self) -> Command {
@ -72,7 +71,7 @@ impl NixCmd {
}
/// 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>
where
T: serde::de::DeserializeOwned,
@ -83,7 +82,7 @@ impl NixCmd {
}
/// 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>
where
T: std::str::FromStr,
@ -96,7 +95,7 @@ impl NixCmd {
}
/// Run nix with given args, returning stdout.
#[cfg(feature = "ssr")]
pub async fn run_with_args_returning_stdout(
&self,
args: &[&str],
@ -132,7 +131,7 @@ impl NixCmd {
}
/// Convert a Command to user-copyable CLI string
#[cfg(feature = "ssr")]
fn to_cli(cmd: &tokio::process::Command) -> String {
use std::ffi::OsStr;
let program = cmd.as_std().get_program().to_string_lossy().to_string();

View File

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

View File

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

View File

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

View File

@ -1,7 +1,10 @@
//! Nix flake outputs
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
///
@ -15,7 +18,7 @@ pub enum FlakeOutputs {
impl FlakeOutputs {
/// Run `nix flake show` on the given flake url
#[cfg(feature = "ssr")]
#[tracing::instrument(name = "flake-show")]
pub async fn from_nix(
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 {
/// Determine [NixInfo] on the user's system
#[cfg(feature = "ssr")]
pub async fn from_nix(
nix_cmd: &crate::command::NixCmd,
) -> Result<NixInfo, crate::command::NixCmdError> {

View File

@ -3,11 +3,11 @@ use regex::Regex;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{fmt, str::FromStr};
use thiserror::Error;
#[cfg(feature = "ssr")]
use tracing::instrument;
/// 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 major: u32,
pub minor: u32,
@ -48,7 +48,7 @@ impl FromStr for NixVersion {
impl NixVersion {
/// Get the output of `nix --version`
#[cfg(feature = "ssr")]
#[instrument(name = "version")]
pub async fn from_nix(
nix_cmd: &super::command::NixCmd,
@ -66,7 +66,6 @@ impl fmt::Display for NixVersion {
}
}
#[cfg(feature = "ssr")]
#[tokio::test]
async fn test_run_nix_version() {
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);
}
#[cfg(feature = "ssr")]
#[tokio::test]
async fn test_parse_nix_version() {
assert_eq!(

View File

@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@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"
}
},
"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": false,
"locked": {
@ -108,22 +124,6 @@
"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": {
"locked": {
"lastModified": 1696234590,
@ -193,8 +193,8 @@
"inputs": {
"cargo-doc-live": "cargo-doc-live",
"crane": "crane",
"dioxus-desktop-template": "dioxus-desktop-template",
"flake-parts": "flake-parts",
"leptos-fullstack": "leptos-fullstack",
"nixpkgs": "nixpkgs",
"process-compose-flake": "process-compose-flake",
"rust-overlay": "rust-overlay_2",

View File

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

View File

@ -6,33 +6,37 @@ default:
# Auto-format the source tree
fmt:
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
# 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
watch $RUST_BACKTRACE="1":
cargo leptos watch
# XXX: hot reload doesn't work with tailwind
# dx serve --hot-reload
dx serve
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
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
# Run tests (backend & frontend)
# Run tests
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)
doc:

117
rust.nix
View File

@ -1,83 +1,48 @@
# 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, ... }:
let
rustBuildInputs = lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [
IOKit
# For when we start using Tauri
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
perSystem = { config, self', pkgs, lib, system, ... }: {
dioxus-desktop = {
overrideCraneArgs = oa: {
nativeBuildInputs = (oa.nativeBuildInputs or [ ]) ++ [
pkgs.nix # cargo tests need nix
];
packages = with pkgs; [
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
'';
meta.description = "WIP: nix-browser";
};
};
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 leptos::*;
use leptos_extra::{
query::{self, RefetchQueryButton},
signal::{use_signal, SignalWithResult},
};
use leptos_meta::*;
use leptos_router::*;
use nix_rs::{
command::Refresh,
flake::{
outputs::{FlakeOutputs, Type, Val},
schema::FlakeSchema,
url::FlakeUrl,
Flake,
},
use dioxus::prelude::*;
use dioxus_router::prelude::Link;
use nix_rs::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]
pub fn NixFlakeRoute(cx: Scope) -> impl IntoView {
let suggestions = FlakeUrl::suggestions();
let url = use_signal::<FlakeUrl>(cx);
let refresh = use_signal::<Refresh>(cx);
let query = move || (url(), refresh());
let result = query::use_server_query(cx, query, get_flake);
view! { cx,
<Title text="Nix Flake"/>
<h1 class="text-5xl font-bold">{"Nix Flake"}</h1>
<TextInput id="nix-flake-input" label="Load a Nix Flake" val=url suggestions/>
<RefetchQueryButton result query/>
<Outlet/>
pub fn Flake(cx: Scope) -> Element {
let state = AppState::use_state(cx);
let fut = use_future(cx, (), |_| async move { state.update_flake().await });
let flake = state.flake.read();
let busy = (*flake).is_loading_or_refreshing();
render! {
h1 { class: "text-5xl font-bold", "Flake dashboard" }
div { class: "p-2 my-1",
input {
class: "w-full p-1 mb-4 font-mono",
id: "nix-flake-input",
"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]
pub fn NixFlakeHomeRoute(cx: Scope) -> impl IntoView {
let url = use_signal::<FlakeUrl>(cx);
let refresh = use_signal::<Refresh>(cx);
let query = move || (url(), refresh());
let result = query::use_server_query(cx, query, get_flake);
let data = result.data;
view! { cx,
<div class="p-2 my-1">
<SuspenseWithErrorHandling>
{move || {
data.with_result(move |flake| {
view! { cx, <FlakeView flake/> }
})
}}
</SuspenseWithErrorHandling>
</div>
pub fn FlakeRaw(cx: Scope) -> Element {
let state = AppState::use_state(cx);
use_future(cx, (), |_| async move { state.update_flake().await });
let flake = state.flake.read();
render! {
div {
Link { to: Route::Flake {}, "⬅ Back" }
div { class: "px-4 py-2 font-mono text-xs text-left text-gray-500 border-2 border-black",
flake.render_with(cx, |v| render! { FlakeOutputsRawView { outs: v.output.clone() } } )
}
}
}
}
#[component]
pub fn NixFlakeRawRoute(cx: Scope) -> impl IntoView {
let url = use_signal::<FlakeUrl>(cx);
let refresh = use_signal::<Refresh>(cx);
let query = move || (url(), refresh());
let result = query::use_server_query(cx, query, get_flake);
let data = result.data;
view! { cx,
<div>
<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>
pub fn FlakeView(cx: Scope, flake: Flake) -> Element {
render! {
div { class: "flex flex-col my-4",
h3 { class: "text-lg font-bold", flake.url.to_string() }
div { class: "text-sm italic text-gray-600",
Link { to: Route::FlakeRaw {}, "View raw output" }
}
div { FlakeSchemaView { schema: &flake.schema } }
}
}
}
#[component]
fn FlakeView<'a>(cx: Scope, flake: &'a Flake) -> impl IntoView {
view! { cx,
<div class="flex flex-col my-4">
<h3 class="text-lg font-bold">{flake.url.to_string()}</h3>
<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>
pub fn SectionHeading(cx: Scope, title: &'static str) -> Element {
render! {
h3 { class: "p-2 mt-4 mb-2 font-bold bg-gray-300 border-b-2 border-l-2 border-black text-l",
"{title}"
}
}
}
#[component]
fn SectionHeading(cx: Scope, title: &'static str) -> impl IntoView {
view! { cx,
<h3 class="p-2 mt-4 mb-2 font-bold bg-gray-300 border-b-2 border-l-2 border-black text-l">
{title}
</h3>
pub fn FlakeSchemaView<'a>(cx: Scope, schema: &'a FlakeSchema) -> Element {
let system = schema.system.clone();
render! {
div {
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]
fn FlakeSchemaView<'a>(cx: Scope, schema: &'a FlakeSchema) -> impl IntoView {
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>(
pub fn BtreeMapView<'a>(
cx: Scope,
title: &'static str,
tree: &'a BTreeMap<String, Val>,
) -> impl IntoView {
(!tree.is_empty()).then(move || {
view! { cx,
<SectionHeading title/>
<BTreeMapBodyView tree/>
) -> Element {
render! {
div {
SectionHeading { title: title }
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]
fn FlakeValView<'a>(cx: Scope, k: &'a String, v: &'a Val) -> impl IntoView {
view! { cx,
<div
title=format!("{:?}", 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>{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> }
})}
pub fn BtreeMapBodyView<'a>(cx: Scope, tree: &'a BTreeMap<String, Val>) -> Element {
render! {
div { class: "flex flex-wrap justify-start",
for (k , v) in tree.iter() {
FlakeValView { k: k.clone(), v: v.clone() }
}
}
}
}
{v
.description
.as_ref()
.map(|v| {
view! { cx, <div class="font-light">{v}</div> }
})}
</div>
#[component]
pub fn FlakeValView(cx: Scope, k: String, v: Val) -> Element {
render! {
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 { 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.
#[component]
fn FlakeOutputsRawView<'a>(cx: Scope, outs: &'a FlakeOutputs) -> impl IntoView {
fn view_val<'b>(cx: Scope, val: &'b Val) -> View {
view! { cx,
<span>
<b>{val.name.clone()}</b>
pub fn FlakeOutputsRawView(cx: Scope, outs: FlakeOutputs) -> Element {
#[component]
fn ValView<'a>(cx: Scope, val: &'a Val) -> Element {
render! {
span {
b { val.name.clone() }
" ("
<TypeView type_=&val.type_/>
TypeView { type_: &val.type_ }
") "
<em>{val.description.clone()}</em>
</span>
em { val.description.clone() }
}
}
.into_view(cx)
}
#[component]
fn TypeView<'b>(cx: Scope, type_: &'b Type) -> impl IntoView {
view! { cx,
<span>
{match type_ {
pub fn TypeView<'a>(cx: Scope, type_: &'a Type) -> Element {
render! {
span {
match type_ {
Type::NixosModule => "nixosModule ❄️",
Type::Derivation => "derivation 📦",
Type::App => "app 📱",
Type::Template => "template 🏗️",
Type::Unknown => "unknown ❓",
}}
</span>
}
}
}
}
match outs {
FlakeOutputs::Val(v) => view_val(cx, v),
FlakeOutputs::Attrset(v) => view! { cx,
<ul class="list-disc">
{v
.iter()
.map(|(k, v)| {
view! { cx,
<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),
FlakeOutputs::Val(v) => render! { ValView { val: v } },
FlakeOutputs::Attrset(v) => render! {
ul { class: "list-disc",
for (k , v) in v.iter() {
li { class: "ml-4",
span { class: "px-2 py-1 font-bold text-primary-500", "{k}" }
FlakeOutputsRawView { outs: v.clone() }
}
}
}
},
}
}
/// 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
use leptos::*;
use leptos_extra::query::{self, RefetchQueryButton};
use leptos_meta::*;
use dioxus::prelude::*;
use nix_health::traits::{Check, CheckResult};
use tracing::instrument;
use crate::widget::*;
use crate::{app::state::AppState, app::widget::RefreshButton};
/// Nix health checks
#[component]
pub fn NixHealthRoute(cx: Scope) -> impl IntoView {
pub fn Health(cx: Scope) -> Element {
let state = AppState::use_state(cx);
let health_checks = state.health_checks.read();
let title = "Nix Health";
let result = query::use_server_query(cx, || (), get_nix_health);
let data = result.data;
view! { cx,
<Title text=title/>
<h1 class="text-5xl font-bold">{title}</h1>
<RefetchQueryButton result query=|| ()/>
<div class="my-1">
<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![])
key=|check| check.title.clone()
view=move |cx, check| {
view! { cx, <ViewCheck check/> }
}
/>
</div>
</SuspenseWithErrorHandling>
</div>
render! {
h1 { class: "text-5xl font-bold", title }
RefreshButton {
busy: (*health_checks).is_loading_or_refreshing(),
handler: move |_event| {
cx.spawn(async move {
state.update_health_checks().await;
});
}
}
health_checks.render_with(cx, |checks| render! {
div { class: "flex flex-col items-stretch justify-start space-y-8 text-left",
for check in checks {
ViewCheck { check: check.clone() }
}
}
})
}
}
#[component]
fn ViewCheck(cx: Scope, check: Check) -> impl IntoView {
view! { cx,
<div class="contents">
<details
open=check.result != CheckResult::Green
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">
<CheckResultSummaryView green=check.result.green()/>
{" "}
{check.title}
</summary>
<div class="p-4">
<div class="p-2 my-2 font-mono text-sm bg-black text-base-100">
{check.info}
</div>
<div class="flex flex-col justify-start space-y-4">
{match check.result {
CheckResult::Green => view! { cx, "" }.into_view(cx),
CheckResult::Red { msg, suggestion } => {
view! { cx,
<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)
fn ViewCheck(cx: Scope, check: Check) -> Element {
render! {
div { class: "contents",
details {
open: check.result != CheckResult::Green,
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",
CheckResultSummaryView { green: check.result.green() }
" "
check.title.clone()
}
div { class: "p-4",
div { class: "p-2 my-2 font-mono text-sm bg-black text-base-100", check.info.clone() }
div { class: "flex flex-col justify-start space-y-4",
match check.result.clone() {
CheckResult::Green => render! { "" },
CheckResult::Red { msg, suggestion } => render! {
h3 { class: "my-2 font-bold text-l" }
div { class: "p-2 bg-red-400 rounded bg-border", msg }
h3 { class: "my-2 font-bold text-l" }
div { class: "p-2 bg-blue-400 rounded bg-border", suggestion }
}
}}
</div>
</div>
</details>
</div>
}
}
}
}
}
}
}
#[component]
pub fn CheckResultSummaryView(cx: Scope, green: bool) -> impl IntoView {
if green {
view! { cx, <span class="text-green-500">{""}</span> }
pub fn CheckResultSummaryView(cx: Scope, green: bool) -> Element {
if *green {
render! { span { class: "text-green-500", "" } }
} 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 leptos::*;
use leptos_extra::query::{self, RefetchQueryButton};
use leptos_extra::signal::SignalWithResult;
use leptos_meta::*;
use nix_rs::{
config::{ConfigVal, NixConfig},
info::NixInfo,
version::NixVersion,
};
use dioxus::prelude::*;
use nix_rs::{config::NixConfig, info::NixInfo, version::NixVersion};
use crate::widget::*;
use crate::{app::state::AppState, app::widget::RefreshButton};
/// Nix information
#[component]
pub fn NixInfoRoute(cx: Scope) -> impl IntoView {
pub fn Info(cx: Scope) -> Element {
let title = "Nix Info";
let result = query::use_server_query(cx, || (), get_nix_info);
let data = result.data;
view! { cx,
<Title text=title/>
<h1 class="text-5xl font-bold">{title}</h1>
<RefetchQueryButton result query=|| ()/>
<div class="my-1 text-left">
<SuspenseWithErrorHandling>
{move || {
data.with_result(move |info| {
view! { cx, <NixInfoView info/> }
})
}}
</SuspenseWithErrorHandling>
</div>
let state = AppState::use_state(cx);
let nix_info = state.nix_info.read();
render! {
h1 { class: "text-5xl font-bold", title }
RefreshButton {
busy: (*nix_info).is_loading_or_refreshing(),
handler: move |_event| {
cx.spawn(async move {
state.update_nix_info().await;
});
}
}
nix_info.render_with(cx, |v| render! { NixInfoView { info: v.clone() } })
}
}
#[component]
fn NixInfoView<'a>(cx: Scope, info: &'a NixInfo) -> impl IntoView {
view! { cx,
<div class="flex flex-col p-4 space-y-8 bg-white border-2 rounded border-base-400">
<div>
<b>Nix Version</b>
<div class="p-1 my-1 rounded bg-primary-50">
<NixVersionView version=&info.nix_version/>
</div>
</div>
<div>
<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>
fn NixInfoView(cx: Scope, info: NixInfo) -> Element {
render! {
div { class: "flex flex-col p-4 space-y-8 bg-white border-2 rounded border-base-400",
div {
b { "Nix Version" }
div { class: "p-1 my-1 rounded bg-primary-50", NixVersionView { version: info.nix_version } }
}
div {
b { "Nix Config" }
NixConfigView { config: info.nix_config.clone() }
}
}
}
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]
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
T: Display,
{
view! { cx,
// Render a list of T items in the list 'self'
<div class="flex flex-col space-y-4">
{cfg
.value
.into_iter()
.map(|item| view! { cx, <li class="list-disc">{item.to_string()}</li> })
.collect_view(cx)}
</div>
render! {
div { class: "flex flex-col space-y-4",
for item in items {
li { class: "list-disc", "{item}" }
}
}
}
.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 health;
mod info;
mod state;
mod widget;
use leptos::*;
use leptos_extra::{
query::{self},
signal::{provide_signal, SignalWithResult},
use dioxus::prelude::*;
use dioxus_router::prelude::*;
use nix_rs::flake::url::FlakeUrl;
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
#[component]
pub fn App(cx: Scope) -> impl IntoView {
provide_meta_context(cx);
provide_query_client(cx);
provide_signal::<FlakeUrl>(
cx,
pub fn App(cx: Scope) -> Element {
AppState::provide_state(cx);
use_shared_state_provider(cx, || {
FlakeUrl::suggestions()
.first()
.map(Clone::clone)
.unwrap_or_default(),
);
provide_signal::<Refresh>(cx, false.into()); // refresh flag is unused, but we may add it to UI later.
.unwrap_or_default()
});
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,
<Stylesheet id="leptos" href="/pkg/nix-browser.css"/>
<Title formatter=|s| format!("{s} ― nix-browser")/>
<Router fallback=|cx| {
view! { cx, <NotFound/> }
}>
<Body 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">
<Nav/>
<main class="flex flex-col px-2 mb-8 space-y-3 text-center">
<Routes>
<Route path="" view=Dashboard/>
<Route path="/flake" view=NixFlakeRoute>
<Route path="" view=NixFlakeHomeRoute/>
<Route path="raw" view=NixFlakeRawRoute/>
</Route>
<Route path="/health" view=NixHealthRoute/>
<Route path="/info" view=NixInfoRoute/>
<Route path="/about" view=About/>
</Routes>
</main>
</div>
</div>
</Router>
// Home page
fn Dashboard(cx: Scope) -> Element {
tracing::debug!("Rendering Dashboard page");
let state = AppState::use_state(cx);
let health_checks = state.health_checks.read();
// A Card component
#[component]
fn Card<'a>(cx: Scope, href: Route, children: Element<'a>) -> Element<'a> {
render! {
Link {
to: "{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 }
}
}
}
render! {
h1 { class: "text-5xl font-bold", "Dashboard" }
div { id: "cards", class: "flex flex-row flex-wrap",
Card { href: Route::Health {},
"Nix Health Check "
match (*health_checks).current_value() {
Some(Ok(checks)) => render! {
if checks.iter().all(|check| check.result.green()) {
""
} else {
""
}
},
Some(Err(err)) => render! { "{err}" },
None => render! { Loader {} },
}
}
Card { href: Route::Info {}, "Nix Info " }
Card { href: Route::Flake {}, "Flake Dashboard ❄️️" }
}
}
}
/// Navigation bar
///
/// TODO Switch to breadcrumbs, as it simplifes the design overall.
#[component]
fn Nav(cx: Scope) -> impl IntoView {
fn Nav(cx: Scope) -> Element {
let class = "px-3 py-2";
view! { cx,
<nav class="flex flex-row w-full mb-8 text-white md:rounded-b bg-primary-800">
<A exact=true href="/" class=class>
"Dashboard"
</A>
<A exact=false href="/flake" class=class>
"Flake"
</A>
<A exact=true href="/health" class=class>
"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>
let active_class = "bg-white text-primary-800 font-bold";
render! {
nav { class: "flex flex-row w-full mb-8 text-white md:rounded-b bg-primary-800",
Link { to: Route::Dashboard {}, class: class, active_class: active_class, "Dashboard" }
Link { to: Route::Flake {}, class: class, active_class: active_class, "Flake" }
Link { to: Route::Health {}, class: class, active_class: active_class, "Nix Health" }
Link { to: Route::Info {}, class: class, active_class: active_class, "Nix Info" }
Link { to: Route::About {}, class: class, active_class: active_class, "About" }
div { class: "flex-grow font-bold text-end {class}", "🌍 nix-browser" }
}
}
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
#[component]
fn About(cx: Scope) -> impl IntoView {
view! { cx,
<Title text="About"/>
<h1 class="text-5xl font-bold">"About"</h1>
<p>
fn About(cx: Scope) -> Element {
render! {
h1 { class: "text-5xl font-bold", "About" }
p {
"nix-browser is still work in progress. Track its development "
<LinkExternal link="https://github.com/juspay/nix-browser" text="on Github"/>
</p>
a {
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
use clap::Parser;
use std::net::SocketAddr;
use crate::logging;
#[derive(Parser, Debug)]
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)]
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
#[cfg(feature = "ssr")]
use tower_http::{
classify::{ServerErrorsAsFailures, SharedClassifier},
trace::TraceLayer,
};
#[cfg(feature = "ssr")]
use tracing_subscriber::filter::{Directive, LevelFilter};
#[cfg(feature = "ssr")]
use tracing_subscriber::EnvFilter;
/// Setup server-side logging using [tracing_subscriber]
#[cfg(feature = "ssr")]
pub fn setup_server_logging(verbosity: &Verbosity) {
pub fn setup_logging(verbosity: &Verbosity) {
tracing_subscriber::fmt()
.with_env_filter(verbosity.log_filter())
.compact()
.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)]
pub struct Verbosity {
/// Server logging level
@ -56,7 +19,6 @@ pub struct Verbosity {
pub verbose: u8,
}
#[cfg(feature = "ssr")]
impl Verbosity {
/// Return the log filter for CLI flag.
fn log_filter(&self) -> EnvFilter {
@ -74,25 +36,18 @@ impl Verbosity {
LevelFilter::WARN.into(),
"nix_browser=info".parse().unwrap(),
"nix_rs=info".parse().unwrap(),
"leptos_extra=info".parse().unwrap(),
],
// -v: log app DEBUG level, as well as http requests
1 => vec![
LevelFilter::WARN.into(),
"nix_browser=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
2 => vec![
LevelFilter::WARN.into(),
"nix_browser=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
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]
async fn main() {
use clap::Parser;
human_panic::setup_panic!();
let args = nix_browser::cli::Args::parse();
nix_browser::server::main(args).await
}
let args = crate::cli::Args::parse();
crate::logging::setup_logging(&args.verbosity);
#[cfg(not(feature = "ssr"))]
fn main() {
// No main entry point for wasm
dioxus_desktop::launch_cfg(
app::App,
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 = {
content: [
"./src/**/*.rs",
"./crates/leptos_extra/**/*.rs"
"./dist/**/*.html"
],
theme: {
fontFamily: {