feat(serve): HTTP+SSE API + static dashboard bundle

Adds the serve subcommand: an axum 0.7 + tower-http server on
:7700 backing the React dashboard. Routes:

  GET  /api/status   /api/projects   /api/snapshots
       /api/sync-queue   /api/hot   /api/events (SSE 5s tick)
  POST /api/activate /api/deactivate /api/sync
       /api/gc /api/snapshot

Mutating endpoints shell out to /usr/local/bin/claw-store so
all CLI logic stays single-sourced. claw-store/static/ holds
the prebuilt dashboard Vite bundle for --static-dir.

cargo_init.rs gets a minor wiring tweak so the API can read
back the managed cargo config marker.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-28 12:52:46 +00:00
co-authored by Claude Opus 4.7
parent 7f57b6e3bd
commit 2fcfb16160
11 changed files with 1189 additions and 6 deletions
Generated
+345
View File
@@ -76,12 +76,96 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -175,13 +259,17 @@ name = "claw-store"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum",
"chrono", "chrono",
"clap", "clap",
"serde", "serde",
"serde_json",
"sysinfo", "sysinfo",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-stream",
"toml", "toml",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -263,12 +351,36 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@@ -321,6 +433,92 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@@ -434,12 +632,34 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.2" version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -513,6 +733,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -618,6 +844,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -673,6 +905,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.9" version = "0.6.9"
@@ -682,6 +925,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -746,6 +1001,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.30.13" version = "0.30.13"
@@ -811,6 +1072,30 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@@ -852,12 +1137,66 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -913,6 +1252,12 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+4
View File
@@ -17,6 +17,10 @@ anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
axum = { version = "0.7", features = ["macros"] }
tower-http = { version = "0.5", features = ["cors", "fs"] }
tokio-stream = "0.1"
serde_json = "1"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+43 -6
View File
@@ -2,7 +2,8 @@ use anyhow::{Context, Result};
use std::path::Path; use std::path::Path;
/// Write .cargo/config.toml in the project's warm directory pointing /// Write .cargo/config.toml in the project's warm directory pointing
/// CARGO_TARGET_DIR at the hot NVMe path. Safe to re-run (idempotent). /// CARGO_TARGET_DIR at the hot NVMe path. Preserves any existing non-[build]
/// sections already in the file. Safe to re-run (idempotent).
pub fn write_cargo_config(warm_path: &Path, hot_target_path: &Path) -> Result<()> { pub fn write_cargo_config(warm_path: &Path, hot_target_path: &Path) -> Result<()> {
let cargo_dir = warm_path.join(".cargo"); let cargo_dir = warm_path.join(".cargo");
std::fs::create_dir_all(&cargo_dir) std::fs::create_dir_all(&cargo_dir)
@@ -11,18 +12,54 @@ pub fn write_cargo_config(warm_path: &Path, hot_target_path: &Path) -> Result<()
let target = hot_target_path.to_str() let target = hot_target_path.to_str()
.context("hot_target_path is not valid UTF-8")?; .context("hot_target_path is not valid UTF-8")?;
let content = format!( let config_path = cargo_dir.join("config.toml");
// Read existing content; strip any prior [build] section so we can replace it.
let existing = if config_path.exists() {
std::fs::read_to_string(&config_path)?
} else {
String::new()
};
// Remove old [build] block (from its header to the next section or EOF).
let stripped = strip_section(&existing, "build");
let new_content = format!(
"# claw-store managed — do not edit manually\n\ "# claw-store managed — do not edit manually\n\
[build]\n\ [build]\n\
target-dir = \"{}\"\n", target-dir = \"{}\"\n\
target {}",
target,
stripped.trim_start()
); );
let config_path = cargo_dir.join("config.toml"); std::fs::write(&config_path, new_content)
std::fs::write(&config_path, content)
.with_context(|| format!("writing {}", config_path.display())) .with_context(|| format!("writing {}", config_path.display()))
} }
/// Remove a TOML section `[name]` and all its key=value lines from `src`,
/// stopping at the next `[section]` header or EOF.
fn strip_section(src: &str, name: &str) -> String {
let header = format!("[{}]", name);
let mut out = String::new();
let mut in_section = false;
for line in src.lines() {
let trimmed = line.trim();
if trimmed == header {
in_section = true;
continue;
}
if in_section && trimmed.starts_with('[') {
in_section = false;
}
if !in_section {
out.push_str(line);
out.push('\n');
}
}
out
}
/// Returns true if .cargo/config.toml exists and points to the expected hot path. /// Returns true if .cargo/config.toml exists and points to the expected hot path.
pub fn verify_cargo_config(warm_path: &Path, hot_target_path: &Path) -> Result<bool> { pub fn verify_cargo_config(warm_path: &Path, hot_target_path: &Path) -> Result<bool> {
let config_path = warm_path.join(".cargo/config.toml"); let config_path = warm_path.join(".cargo/config.toml");
+10
View File
@@ -4,6 +4,7 @@ mod daemon;
mod hot; mod hot;
mod manifest; mod manifest;
mod restore; mod restore;
mod serve;
mod snapshot; mod snapshot;
mod sync; mod sync;
mod zfs; mod zfs;
@@ -54,6 +55,13 @@ enum Cmd {
Restore { project: String, snapshot: String }, Restore { project: String, snapshot: String },
/// Trigger nightly replication to cold tier now /// Trigger nightly replication to cold tier now
Replicate, Replicate,
/// Start the HTTP API server for the dashboard
Serve {
#[arg(long, default_value = "7700")]
port: u16,
#[arg(long)]
static_dir: Option<PathBuf>,
},
} }
#[tokio::main] #[tokio::main]
@@ -84,6 +92,8 @@ async fn main() -> Result<()> {
Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?, Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?,
Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?, Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?,
Cmd::Replicate => cmd_replicate(&cfg, &zfs)?, Cmd::Replicate => cmd_replicate(&cfg, &zfs)?,
Cmd::Serve { port, static_dir } =>
serve::run_server(cfg, manifest, port, static_dir).await?,
} }
Ok(()) Ok(())
} }
+665
View File
@@ -0,0 +1,665 @@
use crate::config::Config;
use crate::manifest::Manifest;
use crate::sync::SyncQueue;
use anyhow::Result;
use axum::{
extract::State,
response::Sse,
routing::{get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::{
convert::Infallible,
path::PathBuf,
sync::Arc,
time::Duration,
};
use tokio_stream::wrappers::IntervalStream;
use tokio_stream::StreamExt;
use tower_http::cors::{Any, CorsLayer};
// ── AppState ──────────────────────────────────────────────────────────────────
pub struct AppState {
pub cfg: Config,
pub manifest_path: PathBuf,
}
// ── API response types ────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct NodeStatus {
pub node_name: String,
pub role: String,
pub hot_used_gb: f64,
pub hot_max_gb: u64,
pub hot_path: String,
pub warm_dataset: String,
pub warm_used_gb: f64,
pub warm_avail_gb: f64,
pub warm_snapshot_count: usize,
pub cold_path: Option<String>,
pub peer_host: Option<String>,
pub peer_reachable: bool,
pub zfs_pool_state: String,
pub active_project_count: usize,
pub daemon_uptime_secs: u64,
}
#[derive(Serialize)]
pub struct ProjectInfo {
pub name: String,
pub warm_path: String,
pub hot_target_path: String,
pub is_active: bool,
pub hot_size_mb: f64,
pub last_build: Option<DateTime<Utc>>,
pub last_active: Option<DateTime<Utc>>,
pub last_sync: Option<DateTime<Utc>>,
pub is_building: bool,
pub git_branch: String,
pub git_ahead: u32,
}
#[derive(Serialize)]
pub struct SnapshotInfo {
pub name: String,
pub kind: String,
pub timestamp: Option<DateTime<Utc>>,
pub used_bytes: u64,
pub refer_bytes: u64,
}
#[derive(Serialize)]
pub struct HotEntry {
pub project: String,
pub size_mb: f64,
pub last_modified: Option<DateTime<Utc>>,
}
#[derive(Deserialize)]
pub struct ProjectBody {
pub project: String,
}
#[derive(Serialize)]
pub struct OkResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
// ── helpers ───────────────────────────────────────────────────────────────────
fn load_manifest(state: &AppState) -> Manifest {
Manifest::load(&state.manifest_path).unwrap_or_default()
}
fn zfs_used_avail(dataset: &str) -> (f64, f64) {
let out = std::process::Command::new("zfs")
.args(["list", "-H", "-p", "-o", "used,avail", dataset])
.output();
match out {
Ok(o) if o.status.success() => {
let s = String::from_utf8_lossy(&o.stdout);
let mut parts = s.split_whitespace();
let used = parts.next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
let avail = parts.next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
(
used as f64 / 1_073_741_824.0,
avail as f64 / 1_073_741_824.0,
)
}
_ => (0.0, 0.0),
}
}
fn zfs_pool_state(dataset: &str) -> String {
let pool = dataset.splitn(2, '/').next().unwrap_or(dataset);
let out = std::process::Command::new("zpool")
.args(["list", "-H", "-o", "health", pool])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "UNKNOWN".to_string(),
}
}
fn zfs_snapshot_count(dataset: &str) -> usize {
let out = std::process::Command::new("zfs")
.args(["list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset])
.output();
match out {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
}
_ => 0,
}
}
fn peer_reachable(peer: &crate::config::PeerConfig) -> bool {
let target = format!("{}@{}", peer.user, peer.host);
std::process::Command::new("ssh")
.args([
"-o", "ConnectTimeout=3",
"-o", "BatchMode=yes",
&target,
"true",
])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn hot_used_gb(manifest: &Manifest) -> f64 {
let total: u64 = manifest.projects.iter().map(|p| {
crate::hot::target_size_bytes(&p.hot_target_path).unwrap_or(0)
}).sum();
total as f64 / 1_073_741_824.0
}
fn du_bytes(path: &std::path::Path) -> u64 {
if !path.exists() { return 0; }
let out = std::process::Command::new("du")
.args(["-sb", path.to_str().unwrap_or("")])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
_ => 0,
}
}
fn git_branch(warm_path: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.args(["-C", warm_path.to_str().unwrap_or(""), "rev-parse", "--abbrev-ref", "HEAD"])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "unknown".to_string(),
}
}
/// Returns true if the project's .cargo/config.toml has a claw-store-managed
/// target-dir pointing at the hot tier (catches projects activated on another node).
fn has_hot_cargo_config(warm_path: &std::path::Path, hot_path: &std::path::Path) -> bool {
let config = warm_path.join(".cargo/config.toml");
if let Ok(content) = std::fs::read_to_string(&config) {
let hot_str = hot_path.to_string_lossy();
content.contains("target-dir") && content.contains(hot_str.as_ref())
} else {
false
}
}
fn git_ahead(warm_path: &std::path::Path) -> u32 {
let out = std::process::Command::new("git")
.args(["-C", warm_path.to_str().unwrap_or(""), "rev-list", "@{u}..HEAD", "--count"])
.output();
match out {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0)
}
_ => 0,
}
}
fn is_building(warm_path: &std::path::Path) -> bool {
let warm_str = match warm_path.to_str() {
Some(s) => s,
None => return false,
};
// Walk /proc/<pid>/cwd and check if it matches warm_path
if let Ok(proc) = std::fs::read_dir("/proc") {
for entry in proc.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.chars().all(|c| c.is_ascii_digit()) { continue; }
let cwd_link = entry.path().join("cwd");
if let Ok(cwd) = std::fs::read_link(&cwd_link) {
if cwd.to_str().map(|s| s.starts_with(warm_str)).unwrap_or(false) {
// check if exe is cargo or rustc
let exe_link = entry.path().join("exe");
if let Ok(exe) = std::fs::read_link(&exe_link) {
let exe_str = exe.to_string_lossy();
if exe_str.contains("cargo") || exe_str.contains("rustc") {
return true;
}
}
}
}
}
}
false
}
fn last_modified_time(path: &std::path::Path) -> Option<DateTime<Utc>> {
path.metadata().ok()
.and_then(|m| m.modified().ok())
.map(|t| {
let secs = t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
DateTime::from_timestamp(secs as i64, 0).unwrap_or_else(Utc::now)
})
}
// Parse snapshot kind from name like "slab/projects@hourly-2026-06-16-0400"
fn parse_snapshot_kind(snap_name: &str) -> String {
// The part after @ is like "hourly-2026-06-16-0400"
let after_at = snap_name.splitn(2, '@').nth(1).unwrap_or(snap_name);
// kind is everything before first '-YYYY'
// Find where the date starts (4-digit year after a dash)
let parts: Vec<&str> = after_at.splitn(2, '-').collect();
parts.first().copied().unwrap_or("unknown").to_string()
}
fn parse_snapshot_timestamp(snap_name: &str) -> Option<DateTime<Utc>> {
// Extract YYYY-MM-DD-HHMM pattern from snapshot name
let after_at = snap_name.splitn(2, '@').nth(1).unwrap_or(snap_name);
// Find a 4-digit year segment
let re_parts: Vec<&str> = after_at.split('-').collect();
// Look for pattern: YYYY-MM-DD-HHMM
for i in 0..re_parts.len().saturating_sub(3) {
if re_parts[i].len() == 4 && re_parts[i].chars().all(|c| c.is_ascii_digit()) {
let yyyy = re_parts[i];
let mm = re_parts.get(i + 1).copied().unwrap_or("01");
let dd = re_parts.get(i + 2).copied().unwrap_or("01");
let hhmm = re_parts.get(i + 3).copied().unwrap_or("0000");
let hh = &hhmm.get(..2).unwrap_or("00");
let mi = &hhmm.get(2..4).unwrap_or("00");
let s = format!("{}-{}-{}T{}:{}:00Z", yyyy, mm, dd, hh, mi);
if let Ok(dt) = s.parse::<DateTime<Utc>>() {
return Some(dt);
}
}
}
None
}
async fn run_claw_store_cmd(args: &[&str]) -> OkResponse {
let result = tokio::process::Command::new("/usr/local/bin/claw-store")
.args(args)
.output()
.await;
match result {
Ok(o) if o.status.success() => OkResponse { ok: true, error: None },
Ok(o) => OkResponse {
ok: false,
error: Some(String::from_utf8_lossy(&o.stderr).trim().to_string()),
},
Err(e) => OkResponse {
ok: false,
error: Some(e.to_string()),
},
}
}
// ── route handlers ────────────────────────────────────────────────────────────
async fn handle_status(State(state): State<Arc<AppState>>) -> Json<NodeStatus> {
let manifest = load_manifest(&state);
let cfg = &state.cfg;
let (warm_used_gb, warm_avail_gb) = zfs_used_avail(&cfg.warm.zfs_dataset);
let warm_snapshot_count = zfs_snapshot_count(&cfg.warm.zfs_dataset);
let zfs_pool_state = zfs_pool_state(&cfg.warm.zfs_dataset);
let hot_used = hot_used_gb(&manifest);
let (peer_host, reachable) = match &cfg.peer {
Some(p) => (Some(p.host.clone()), peer_reachable(p)),
None => (None, false),
};
let role = match cfg.node.role {
crate::config::NodeRole::Primary => "primary",
crate::config::NodeRole::Secondary => "secondary",
};
Json(NodeStatus {
node_name: cfg.node.name.clone(),
role: role.to_string(),
hot_used_gb: hot_used,
hot_max_gb: cfg.hot.max_gb,
hot_path: cfg.hot.path.to_string_lossy().to_string(),
warm_dataset: cfg.warm.zfs_dataset.clone(),
warm_used_gb,
warm_avail_gb,
warm_snapshot_count,
cold_path: cfg.cold.as_ref().map(|c| c.archive_path.to_string_lossy().to_string()),
peer_host,
peer_reachable: reachable,
zfs_pool_state,
active_project_count: manifest.projects.len(),
daemon_uptime_secs: 0,
})
}
async fn handle_projects(State(state): State<Arc<AppState>>) -> Json<Vec<ProjectInfo>> {
let manifest = load_manifest(&state);
let cfg = &state.cfg;
let base = &cfg.warm.projects_path;
let mut projects: Vec<ProjectInfo> = Vec::new();
if let Ok(top_entries) = std::fs::read_dir(base) {
for org_entry in top_entries.flatten() {
let org_path = org_entry.path();
if !org_path.is_dir() { continue; }
let org = org_entry.file_name().to_string_lossy().to_string();
if org_path.join(".git").exists() {
let active = manifest.get(&org);
let hot_path = cfg.hot.path.join(&org);
let size_bytes = du_bytes(&hot_path);
projects.push(ProjectInfo {
name: org.clone(),
warm_path: org_path.to_string_lossy().to_string(),
hot_target_path: hot_path.to_string_lossy().to_string(),
is_active: active.is_some() || has_hot_cargo_config(&org_path, &hot_path),
hot_size_mb: size_bytes as f64 / 1_048_576.0,
last_build: active.and_then(|p| p.last_build),
last_active: active.and_then(|p| p.last_active),
last_sync: active.and_then(|p| p.last_sync),
is_building: is_building(&org_path),
git_branch: git_branch(&org_path),
git_ahead: git_ahead(&org_path),
});
} else if let Ok(repos) = std::fs::read_dir(&org_path) {
for repo_entry in repos.flatten() {
let repo_path = repo_entry.path();
if !repo_path.is_dir() || !repo_path.join(".git").exists() { continue; }
let repo = repo_entry.file_name().to_string_lossy().to_string();
let key = format!("{}/{}", org, repo);
let active = manifest.get(&key);
let hot_path = cfg.hot.path.join(&key);
let size_bytes = du_bytes(&hot_path);
projects.push(ProjectInfo {
name: key.clone(),
warm_path: repo_path.to_string_lossy().to_string(),
hot_target_path: hot_path.to_string_lossy().to_string(),
is_active: active.is_some() || has_hot_cargo_config(&repo_path, &hot_path),
hot_size_mb: size_bytes as f64 / 1_048_576.0,
last_build: active.and_then(|p| p.last_build),
last_active: active.and_then(|p| p.last_active),
last_sync: active.and_then(|p| p.last_sync),
is_building: is_building(&repo_path),
git_branch: git_branch(&repo_path),
git_ahead: git_ahead(&repo_path),
});
}
}
}
}
projects.sort_by(|a, b| a.name.cmp(&b.name));
Json(projects)
}
async fn handle_snapshots(State(state): State<Arc<AppState>>) -> Json<Vec<SnapshotInfo>> {
let cfg = &state.cfg;
let dataset = &cfg.warm.zfs_dataset;
let out = std::process::Command::new("zfs")
.args(["list", "-H", "-p", "-t", "snapshot", "-o", "name,used,refer", "-r", dataset])
.output();
let mut snapshots = Vec::new();
if let Ok(o) = out {
let text = String::from_utf8_lossy(&o.stdout);
for line in text.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 { continue; }
let name = parts[0].trim().to_string();
let used_bytes = parts[1].trim().parse::<u64>().unwrap_or(0);
let refer_bytes = parts[2].trim().parse::<u64>().unwrap_or(0);
let kind = parse_snapshot_kind(&name);
let timestamp = parse_snapshot_timestamp(&name);
snapshots.push(SnapshotInfo { name, kind, timestamp, used_bytes, refer_bytes });
}
}
Json(snapshots)
}
async fn handle_sync_queue(State(_state): State<Arc<AppState>>) -> Json<Vec<crate::sync::SyncJob>> {
let queue = SyncQueue::load(&SyncQueue::default_path()).unwrap_or_default();
Json(queue.jobs)
}
async fn handle_hot(State(state): State<Arc<AppState>>) -> Json<Vec<HotEntry>> {
let hot_base = &state.cfg.hot.path;
let mut entries = Vec::new();
if let Ok(top) = std::fs::read_dir(hot_base) {
for org_entry in top.flatten() {
let org_path = org_entry.path();
if !org_path.is_dir() { continue; }
let org_name = org_entry.file_name().to_string_lossy().to_string();
if let Ok(repos) = std::fs::read_dir(&org_path) {
for repo_entry in repos.flatten() {
let repo_path = repo_entry.path();
if !repo_path.is_dir() { continue; }
let repo_name = repo_entry.file_name().to_string_lossy().to_string();
let project = format!("{}/{}", org_name, repo_name);
let size_bytes = du_bytes(&repo_path);
let last_modified = last_modified_time(&repo_path);
entries.push(HotEntry {
project,
size_mb: size_bytes as f64 / 1_048_576.0,
last_modified,
});
}
} else {
// Flat layout — org_path itself is the project dir
let size_bytes = du_bytes(&org_path);
let last_modified = last_modified_time(&org_path);
entries.push(HotEntry {
project: org_name,
size_mb: size_bytes as f64 / 1_048_576.0,
last_modified,
});
}
}
}
entries.sort_by(|a, b| a.project.cmp(&b.project));
Json(entries)
}
async fn handle_activate(
State(_state): State<Arc<AppState>>,
Json(body): Json<ProjectBody>,
) -> Json<OkResponse> {
Json(run_claw_store_cmd(&["activate", &body.project]).await)
}
async fn handle_deactivate(
State(_state): State<Arc<AppState>>,
Json(body): Json<ProjectBody>,
) -> Json<OkResponse> {
Json(run_claw_store_cmd(&["deactivate", &body.project]).await)
}
async fn handle_sync(
State(_state): State<Arc<AppState>>,
Json(body): Json<ProjectBody>,
) -> Json<OkResponse> {
Json(run_claw_store_cmd(&["sync", &body.project]).await)
}
async fn handle_gc(State(_state): State<Arc<AppState>>) -> Json<OkResponse> {
Json(run_claw_store_cmd(&["gc"]).await)
}
async fn handle_snapshot(State(_state): State<Arc<AppState>>) -> Json<OkResponse> {
Json(run_claw_store_cmd(&["snapshot"]).await)
}
// ── SSE event stream ──────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SsePayload {
status: NodeStatus,
projects: Vec<ProjectInfo>,
}
async fn handle_events(
State(state): State<Arc<AppState>>,
) -> Sse<impl tokio_stream::Stream<Item = Result<axum::response::sse::Event, Infallible>>> {
let interval = tokio::time::interval(Duration::from_secs(5));
let stream = IntervalStream::new(interval).map(move |_| {
let manifest = load_manifest(&state);
let cfg = &state.cfg;
// Build status
let (warm_used_gb, warm_avail_gb) = zfs_used_avail(&cfg.warm.zfs_dataset);
let warm_snapshot_count = zfs_snapshot_count(&cfg.warm.zfs_dataset);
let zfs_pool_state = zfs_pool_state(&cfg.warm.zfs_dataset);
let hot_used = hot_used_gb(&manifest);
let (peer_host, reachable) = match &cfg.peer {
Some(p) => (Some(p.host.clone()), peer_reachable(p)),
None => (None, false),
};
let role = match cfg.node.role {
crate::config::NodeRole::Primary => "primary",
crate::config::NodeRole::Secondary => "secondary",
};
let status = NodeStatus {
node_name: cfg.node.name.clone(),
role: role.to_string(),
hot_used_gb: hot_used,
hot_max_gb: cfg.hot.max_gb,
hot_path: cfg.hot.path.to_string_lossy().to_string(),
warm_dataset: cfg.warm.zfs_dataset.clone(),
warm_used_gb,
warm_avail_gb,
warm_snapshot_count,
cold_path: cfg.cold.as_ref().map(|c| c.archive_path.to_string_lossy().to_string()),
peer_host,
peer_reachable: reachable,
zfs_pool_state,
active_project_count: manifest.projects.len(),
daemon_uptime_secs: 0,
};
// Build projects list
let base = &cfg.warm.projects_path;
let mut projects: Vec<ProjectInfo> = Vec::new();
if let Ok(top_entries) = std::fs::read_dir(base) {
for org_entry in top_entries.flatten() {
let org_path = org_entry.path();
if !org_path.is_dir() { continue; }
let org = org_entry.file_name().to_string_lossy().to_string();
if org_path.join(".git").exists() {
let active = manifest.get(&org);
let hot_path = cfg.hot.path.join(&org);
let size_bytes = du_bytes(&hot_path);
projects.push(ProjectInfo {
name: org.clone(),
warm_path: org_path.to_string_lossy().to_string(),
hot_target_path: hot_path.to_string_lossy().to_string(),
is_active: active.is_some(),
hot_size_mb: size_bytes as f64 / 1_048_576.0,
last_build: active.and_then(|p| p.last_build),
last_active: active.and_then(|p| p.last_active),
last_sync: active.and_then(|p| p.last_sync),
is_building: is_building(&org_path),
git_branch: git_branch(&org_path),
git_ahead: git_ahead(&org_path),
});
} else if let Ok(repos) = std::fs::read_dir(&org_path) {
for repo_entry in repos.flatten() {
let repo_path = repo_entry.path();
if !repo_path.is_dir() || !repo_path.join(".git").exists() { continue; }
let repo = repo_entry.file_name().to_string_lossy().to_string();
let key = format!("{}/{}", org, repo);
let active = manifest.get(&key);
let hot_path = cfg.hot.path.join(&key);
let size_bytes = du_bytes(&hot_path);
projects.push(ProjectInfo {
name: key.clone(),
warm_path: repo_path.to_string_lossy().to_string(),
hot_target_path: hot_path.to_string_lossy().to_string(),
is_active: active.is_some(),
hot_size_mb: size_bytes as f64 / 1_048_576.0,
last_build: active.and_then(|p| p.last_build),
last_active: active.and_then(|p| p.last_active),
last_sync: active.and_then(|p| p.last_sync),
is_building: is_building(&repo_path),
git_branch: git_branch(&repo_path),
git_ahead: git_ahead(&repo_path),
});
}
}
}
}
projects.sort_by(|a, b| a.name.cmp(&b.name));
let payload = SsePayload { status, projects };
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
Ok(axum::response::sse::Event::default().data(data))
});
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(15))
.text(":keep-alive"),
)
}
// ── server entry point ────────────────────────────────────────────────────────
pub async fn run_server(
cfg: Config,
_manifest: Manifest,
port: u16,
static_dir: Option<PathBuf>,
) -> Result<()> {
let manifest_path = Manifest::default_path();
let state = Arc::new(AppState { cfg, manifest_path });
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
])
.allow_headers([axum::http::header::CONTENT_TYPE]);
let mut api: Router<Arc<AppState>> = Router::new()
.route("/api/status", get(handle_status))
.route("/api/projects", get(handle_projects))
.route("/api/snapshots", get(handle_snapshots))
.route("/api/sync-queue", get(handle_sync_queue))
.route("/api/hot", get(handle_hot))
.route("/api/activate", post(handle_activate))
.route("/api/deactivate", post(handle_deactivate))
.route("/api/sync", post(handle_sync))
.route("/api/gc", post(handle_gc))
.route("/api/snapshot", post(handle_snapshot))
.route("/api/events", get(handle_events));
if let Some(dir) = static_dir {
api = api.fallback_service(tower_http::services::ServeDir::new(dir));
}
let app: Router = api.layer(cors).with_state(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("claw-store API server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>claw-store dashboard</title>
<script type="module" crossorigin src="/assets/index-CRZ1mOvb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DkWBsNwc.css">
</head>
<body>
<div id="root"></div>
</body>
</html>