From a78a8fdef1f685d7fb3eefff88f2252e70b329e9 Mon Sep 17 00:00:00 2001 From: lucalise Date: Mon, 12 Jan 2026 00:09:22 -0800 Subject: [PATCH] feat(homelab)!: migrate to svelte kit, add more server stats endpoints --- nix/homelab/api/.env | 1 - nix/homelab/api/.gitignore | 1 + nix/homelab/api/Cargo.lock | 163 ++++++++++++- nix/homelab/api/Cargo.toml | 5 +- nix/homelab/api/src/endpoints/kubernetes.rs | 36 +++ nix/homelab/api/src/endpoints/mod.rs | 4 + nix/homelab/api/src/endpoints/server_stats.rs | 14 +- .../api/src/endpoints/server_uptime.rs | 56 +++++ nix/homelab/api/src/endpoints/world_size.rs | 56 +++++ nix/homelab/api/src/error.rs | 8 + nix/homelab/api/src/kubernetes.rs | 219 ++++++++++++++++++ nix/homelab/api/src/main.rs | 34 ++- nix/homelab/frontend/.gitignore | 2 + nix/homelab/frontend/.prettierrc | 5 +- nix/homelab/frontend/bun.lock | 35 ++- nix/homelab/frontend/package.json | 10 +- nix/homelab/frontend/src/app.d.ts | 13 ++ .../frontend/{index.html => src/app.html} | 11 +- .../frontend/src/lib/minecraft/Stats.svelte | 25 +- .../frontend/src/lib/minecraft/stats.ts | 9 +- nix/homelab/frontend/src/main.ts | 9 - .../frontend/src/routes/+layout.svelte | 7 + nix/homelab/frontend/src/routes/+layout.ts | 2 + .../src/{App.svelte => routes/+page.svelte} | 18 +- .../src/{app.css => routes/layout.css} | 0 .../frontend/{public => static}/vite.svg | 0 nix/homelab/frontend/svelte.config.js | 12 +- nix/homelab/frontend/tsconfig.app.json | 21 -- nix/homelab/frontend/tsconfig.json | 24 +- nix/homelab/frontend/tsconfig.node.json | 26 --- nix/homelab/frontend/vite.config.ts | 4 +- 31 files changed, 722 insertions(+), 108 deletions(-) delete mode 100644 nix/homelab/api/.env create mode 100644 nix/homelab/api/src/endpoints/kubernetes.rs create mode 100644 nix/homelab/api/src/endpoints/server_uptime.rs create mode 100644 nix/homelab/api/src/endpoints/world_size.rs create mode 100644 nix/homelab/api/src/kubernetes.rs create mode 100644 nix/homelab/frontend/src/app.d.ts rename nix/homelab/frontend/{index.html => src/app.html} (52%) delete mode 100644 nix/homelab/frontend/src/main.ts create mode 100644 nix/homelab/frontend/src/routes/+layout.svelte create mode 100644 nix/homelab/frontend/src/routes/+layout.ts rename nix/homelab/frontend/src/{App.svelte => routes/+page.svelte} (51%) rename nix/homelab/frontend/src/{app.css => routes/layout.css} (100%) rename nix/homelab/frontend/{public => static}/vite.svg (100%) delete mode 100644 nix/homelab/frontend/tsconfig.app.json delete mode 100644 nix/homelab/frontend/tsconfig.node.json diff --git a/nix/homelab/api/.env b/nix/homelab/api/.env deleted file mode 100644 index b5203d4..0000000 --- a/nix/homelab/api/.env +++ /dev/null @@ -1 +0,0 @@ -RCON_PASSWORD=Kl5pONawkLEMSnfi diff --git a/nix/homelab/api/.gitignore b/nix/homelab/api/.gitignore index eb5a316..c5dd462 100644 --- a/nix/homelab/api/.gitignore +++ b/nix/homelab/api/.gitignore @@ -1 +1,2 @@ target +.env diff --git a/nix/homelab/api/Cargo.lock b/nix/homelab/api/Cargo.lock index 1a13db5..9ceb9f7 100644 --- a/nix/homelab/api/Cargo.lock +++ b/nix/homelab/api/Cargo.lock @@ -234,6 +234,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -378,8 +387,12 @@ version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -461,6 +474,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.5" @@ -669,6 +688,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -691,6 +711,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -833,7 +864,9 @@ name = "homelab-api" version = "0.1.0" dependencies = [ "actix-web", + "chrono", "dotenvy", + "futures", "k8s-openapi", "kube", "nom", @@ -842,6 +875,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", + "tokio-util", ] [[package]] @@ -984,6 +1018,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1217,6 +1275,7 @@ dependencies = [ "serde_yaml", "thiserror 2.0.17", "tokio", + "tokio-tungstenite", "tokio-util", "tower", "tower-http", @@ -2124,9 +2183,21 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2 0.6.1", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2137,6 +2208,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2236,6 +2319,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2290,6 +2390,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2371,12 +2477,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2650,9 +2809,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" [[package]] name = "zstd" diff --git a/nix/homelab/api/Cargo.toml b/nix/homelab/api/Cargo.toml index 68bc2c7..15da82e 100644 --- a/nix/homelab/api/Cargo.toml +++ b/nix/homelab/api/Cargo.toml @@ -12,5 +12,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.17" tokio = "1.49.0" -kube = { version = "2.0.1", features = ["client", "runtime"] } +tokio-util = { version = "0.7", features = ["io"] } +kube = { version = "2.0.1", features = ["client", "runtime", "ws"] } k8s-openapi = { version = "0.26", features = ["v1_32"] } +chrono = "0.4" +futures = "0.3" diff --git a/nix/homelab/api/src/endpoints/kubernetes.rs b/nix/homelab/api/src/endpoints/kubernetes.rs new file mode 100644 index 0000000..96ac784 --- /dev/null +++ b/nix/homelab/api/src/endpoints/kubernetes.rs @@ -0,0 +1,36 @@ +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; +use crate::error::Result; +use crate::kubernetes; + +#[derive(Deserialize)] +pub struct RestoreRequest { + pub backup_file: String, +} + +#[derive(Serialize)] +pub struct RestoreResponse { + pub server: String, + pub job_name: String, + pub backup_file: String, + pub status: String, +} + +/// POST /api/minecraft/{server}/restore +pub async fn create_restore( + app_state: web::Data, + path: web::Path, + body: web::Json, +) -> Result { + let job_name = + kubernetes::create_restore_job(&app_state.kube, &path.server, &body.backup_file).await?; + + Ok(HttpResponse::Created().json(RestoreResponse { + server: path.server.clone(), + job_name, + backup_file: body.backup_file.clone(), + status: "created".to_string(), + })) +} diff --git a/nix/homelab/api/src/endpoints/mod.rs b/nix/homelab/api/src/endpoints/mod.rs index 43f7377..1eac0e5 100644 --- a/nix/homelab/api/src/endpoints/mod.rs +++ b/nix/homelab/api/src/endpoints/mod.rs @@ -1 +1,5 @@ pub mod server_stats; +pub mod server_uptime; +pub mod world_size; + +pub(self) const MINECRAFT_NAMESPACE: &str = "minecraft"; diff --git a/nix/homelab/api/src/endpoints/server_stats.rs b/nix/homelab/api/src/endpoints/server_stats.rs index 8f6abc3..86a7a67 100644 --- a/nix/homelab/api/src/endpoints/server_stats.rs +++ b/nix/homelab/api/src/endpoints/server_stats.rs @@ -1,14 +1,19 @@ use actix_web::{HttpResponse, web}; use serde::Serialize; -use crate::{AppState, error::Result, rcon::parse_online_list}; +use crate::{ + AppState, + endpoints::server_uptime::{PodUptime, get_pod_uptime}, + error::Result, + rcon::parse_online_list, +}; #[derive(Serialize)] -pub struct ServerStats { +struct ServerStats { pub status: String, pub players_online: u16, pub max_players: u16, - pub uptime: Option, + pub uptime: PodUptime, pub world_size: Option, } @@ -17,12 +22,13 @@ pub async fn get_server_stats(app_state: web::Data) -> Result, + path: web::Path, +) -> Result { + let server = path.into_inner(); + let uptime = get_pod_uptime(&app_state.kube, &server).await?; + + Ok(HttpResponse::Ok().json(uptime)) +} + +pub(super) async fn find_minecraft_pod(client: &Client, server_name: &str) -> Result { + let pods: Api = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE); + + let label_selector = format!("app=minecraft-{server_name}"); + let lp = ListParams::default().labels(&label_selector); + + let pod_list = pods.list(&lp).await?; + + pod_list + .items + .into_iter() + .next() + .ok_or_else(|| Error::PodNotFound(format!("minecraft-{server_name}"))) +} + +pub(super) async fn get_pod_uptime(client: &Client, server_name: &str) -> Result { + let pod = find_minecraft_pod(client, server_name).await?; + + let start_time = pod + .status + .and_then(|s| s.start_time) + .ok_or_else(|| Error::PodExec("pod has no start time".into()))?; + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(&start_time.0); + + Ok(PodUptime { + seconds: duration.num_seconds(), + started_at: start_time.0.to_rfc3339(), + }) +} diff --git a/nix/homelab/api/src/endpoints/world_size.rs b/nix/homelab/api/src/endpoints/world_size.rs new file mode 100644 index 0000000..72b0121 --- /dev/null +++ b/nix/homelab/api/src/endpoints/world_size.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use k8s_openapi::api::core::v1::Pod; +use kube::{Api, Client}; +use serde::Serialize; + +use crate::{ + AppState, + endpoints::{MINECRAFT_NAMESPACE, server_uptime::find_minecraft_pod}, + error::{Error, Result}, +}; + +#[derive(Serialize)] +pub struct WorldSizeResponse { + pub server: String, + pub size: String, +} + +pub async fn get_world_size( + app_state: web::Data, + path: web::Path, +) -> Result { + let server = path.into_inner(); + let size = get_size_inner(&app_state.kube, &server).await?; + + Ok(HttpResponse::Ok().json(WorldSizeResponse { + server: server.clone(), + size, + })) +} + +pub async fn get_size_inner(client: &Client, server_name: &str) -> Result { + let pod = find_minecraft_pod(client, server_name).await?; + let pod_name = pod + .metadata + .name + .ok_or_else(|| Error::PodNotFound("no pod name".into()))?; + + let output = exec_in_pod(client, &pod_name, vec!["du", "-sh", "/data"]).await?; + + let size = output + .split_whitespace() + .next() + .unwrap_or("unknown") + .to_string(); + + Ok(size) +} + +async fn exec_in_pod(client: &Client, pod_name: &str, command: &str) { + let pods: Api = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE); + + const params = AttachParams { + stdin: true, + ..Default::default() + }; +} diff --git a/nix/homelab/api/src/error.rs b/nix/homelab/api/src/error.rs index fd49eb8..4c78814 100644 --- a/nix/homelab/api/src/error.rs +++ b/nix/homelab/api/src/error.rs @@ -9,12 +9,20 @@ pub enum Error { Rcon(#[from] rcon::Error), #[error("parse error: {0}")] Parse(String), + #[error("kubernetes error: {0}")] + Kube(#[from] kube::Error), + #[error("pod not found: {0}")] + PodNotFound(String), + #[error("pod exec error: {0}")] + PodExec(String), } impl ResponseError for Error { fn status_code(&self) -> StatusCode { match self { Self::Rcon(_) => StatusCode::SERVICE_UNAVAILABLE, + Self::PodNotFound(_) => StatusCode::NOT_FOUND, + Self::Kube(_) | Self::PodExec(_) => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/nix/homelab/api/src/kubernetes.rs b/nix/homelab/api/src/kubernetes.rs new file mode 100644 index 0000000..b591847 --- /dev/null +++ b/nix/homelab/api/src/kubernetes.rs @@ -0,0 +1,219 @@ +use futures::TryStreamExt; +use k8s_openapi::api::batch::v1::Job; +use k8s_openapi::api::core::v1::Pod; +use kube::Client; +use kube::api::{Api, AttachParams, ListParams, PostParams}; + +use crate::error::{Error, Result}; + +const NFS_SERVER: &str = "192.168.27.2"; +const NFS_BACKUP_PATH: &str = "/backup/minecraft"; + +/// Find the Minecraft server pod by server name (e.g., "main", "creative") + +/// Execute a command in a pod and return stdout +pub async fn exec_in_pod(client: &Client, pod_name: &str, command: Vec<&str>) -> Result { + let pods: Api = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE); + + let ap = AttachParams { + stdout: true, + stderr: true, + ..Default::default() + }; + + let mut attached = pods.exec(pod_name, command, &ap).await?; + + let mut stdout_str = String::new(); + if let Some(stdout) = attached.stdout() { + let bytes: Vec = tokio_util::io::ReaderStream::new(stdout) + .try_collect::>() + .await + .map_err(|e| Error::PodExec(e.to_string()))? + .into_iter() + .flatten() + .collect(); + stdout_str = String::from_utf8_lossy(&bytes).to_string(); + } + + // Wait for exec to finish + attached + .join() + .await + .map_err(|e| Error::PodExec(e.to_string()))?; + + Ok(stdout_str.trim().to_string()) +} + +/// Get the world size by running du -sh /data in the pod +/// Get pod uptime by calculating time since pod started + +/// Create a restore job for a Minecraft server +pub async fn create_restore_job( + client: &Client, + server_name: &str, + backup_file: &str, +) -> Result { + let jobs: Api = Api::namespaced(client.clone(), MINECRAFT_NAMESPACE); + + let job = build_restore_job(server_name, backup_file); + let job_name = job + .metadata + .name + .clone() + .unwrap_or_else(|| "unknown".into()); + + jobs.create(&PostParams::default(), &job).await?; + + Ok(job_name) +} + +fn build_restore_job(server_name: &str, backup_file: &str) -> Job { + use k8s_openapi::api::core::v1::{ + Container, EnvVar, NFSVolumeSource, PersistentVolumeClaimVolumeSource, PodSecurityContext, + PodSpec, PodTemplateSpec, Volume, VolumeMount, + }; + + let job_name = format!( + "minecraft-restore-{server_name}-{}", + chrono::Utc::now().timestamp() + ); + + let restore_script = r#" +set -e + +echo "==========================================" +echo "Minecraft World Restore Job" +echo "==========================================" +echo "" + +SERVER_NAME="${SERVER_NAME}" +BACKUP_FILE="${BACKUP_FILE:-latest.tgz}" +BACKUP_PATH="/backups/${BACKUP_FILE}" +DATA_DIR="/data" + +echo "Configuration:" +echo " Server: ${SERVER_NAME}" +echo " Backup file: ${BACKUP_FILE}" +echo " Backup path: ${BACKUP_PATH}" +echo " Data directory: ${DATA_DIR}" +echo "" + +if [ ! -f "${BACKUP_PATH}" ]; then + echo "ERROR: Backup file not found: ${BACKUP_PATH}" + echo "" + echo "Available backups:" + ls -lh /backups/ | grep "minecraft-${SERVER_NAME}" || echo " (none found)" + exit 1 +fi + +echo "Backup file found" +echo " Size: $(du -hL ${BACKUP_PATH} | cut -f1)" +echo "" + +if [ -d "${DATA_DIR}/world" ]; then + echo "WARNING: Existing world data found!" + echo "Removing existing world data..." + rm -rf "${DATA_DIR}/world" "${DATA_DIR}/world_nether" "${DATA_DIR}/world_the_end" + echo "Old world data removed" +fi +echo "" + +echo "Extracting backup..." +tar -xzf "${BACKUP_PATH}" -C "${DATA_DIR}/" + +echo "" +echo "==========================================" +echo "Restore Complete!" +echo "==========================================" +"#; + + Job { + metadata: kube::core::ObjectMeta { + name: Some(job_name), + namespace: Some(MINECRAFT_NAMESPACE.to_string()), + labels: Some( + [("app".to_string(), "minecraft-restore".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }, + spec: Some(k8s_openapi::api::batch::v1::JobSpec { + backoff_limit: Some(0), + ttl_seconds_after_finished: Some(3600), + template: PodTemplateSpec { + metadata: Some(kube::core::ObjectMeta { + labels: Some( + [("app".to_string(), "minecraft-restore".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }), + spec: Some(PodSpec { + restart_policy: Some("Never".to_string()), + security_context: Some(PodSecurityContext { + fs_group: Some(2000), + run_as_user: Some(1000), + run_as_group: Some(3000), + ..Default::default() + }), + containers: vec![Container { + name: "restore".to_string(), + image: Some("busybox:latest".to_string()), + command: Some(vec!["sh".to_string(), "-c".to_string()]), + args: Some(vec![restore_script.to_string()]), + env: Some(vec![ + EnvVar { + name: "SERVER_NAME".to_string(), + value: Some(server_name.to_string()), + ..Default::default() + }, + EnvVar { + name: "BACKUP_FILE".to_string(), + value: Some(backup_file.to_string()), + ..Default::default() + }, + ]), + volume_mounts: Some(vec![ + VolumeMount { + name: "data".to_string(), + mount_path: "/data".to_string(), + ..Default::default() + }, + VolumeMount { + name: "backups".to_string(), + mount_path: "/backups".to_string(), + read_only: Some(true), + ..Default::default() + }, + ]), + ..Default::default() + }], + volumes: Some(vec![ + Volume { + name: "data".to_string(), + persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource { + claim_name: format!("minecraft-{server_name}-datadir"), + ..Default::default() + }), + ..Default::default() + }, + Volume { + name: "backups".to_string(), + nfs: Some(NFSVolumeSource { + server: NFS_SERVER.to_string(), + path: NFS_BACKUP_PATH.to_string(), + ..Default::default() + }), + ..Default::default() + }, + ]), + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + } +} diff --git a/nix/homelab/api/src/main.rs b/nix/homelab/api/src/main.rs index 3ef4a58..aeeb1f0 100644 --- a/nix/homelab/api/src/main.rs +++ b/nix/homelab/api/src/main.rs @@ -5,11 +5,13 @@ mod rcon; use std::env; use actix_web::{App, HttpServer, web}; +use kube::Client; use crate::rcon::RconClient; -struct AppState { +pub struct AppState { rcon: RconClient, + kube: Client, } struct Env { @@ -36,8 +38,16 @@ fn load_env() -> Env { #[actix_web::main] async fn main() -> std::io::Result<()> { let env = load_env(); + + // Initialize Kubernetes client + // Uses in-cluster config when running in k3s, falls back to ~/.kube/config locally + let kube_client = Client::try_default() + .await + .expect("failed to create Kubernetes client"); + let app_state = web::Data::new(AppState { rcon: RconClient::new(env.rcon_password), + kube: kube_client, }); HttpServer::new(move || { @@ -48,10 +58,24 @@ async fn main() -> std::io::Result<()> { web::get() .to(async || concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))), ) - .service(web::scope("/api").route( - "/minecraft-server-stats", - web::get().to(endpoints::server_stats::get_server_stats), - )) + .service( + web::scope("/api") + .route( + "/minecraft-server-stats", + web::get().to(endpoints::server_stats::get_server_stats), + ) + // .route( + // "/minecraft/{server}/world-size", + // web::get().to(endpoints::kubernetes::get_world_size), + // ) + .route( + "/minecraft/{server}/uptime", + web::get().to(endpoints::server_uptime::get_uptime), + ), // .route( + // "/minecraft/{server}/restore", + // web::post().to(endpoints::kubernetes::create_restore), + // ), + ) }) .bind(("127.0.0.1", 8080))? .run() diff --git a/nix/homelab/frontend/.gitignore b/nix/homelab/frontend/.gitignore index a547bf3..37bedf5 100644 --- a/nix/homelab/frontend/.gitignore +++ b/nix/homelab/frontend/.gitignore @@ -6,6 +6,8 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +/.svelte-kit +build node_modules dist diff --git a/nix/homelab/frontend/.prettierrc b/nix/homelab/frontend/.prettierrc index b7ff738..93ea2ea 100644 --- a/nix/homelab/frontend/.prettierrc +++ b/nix/homelab/frontend/.prettierrc @@ -1 +1,4 @@ -{ plugins: [prettier-plugin-svelte] } +{ + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/nix/homelab/frontend/bun.lock b/nix/homelab/frontend/bun.lock index 5d83078..6e42d46 100644 --- a/nix/homelab/frontend/bun.lock +++ b/nix/homelab/frontend/bun.lock @@ -10,18 +10,21 @@ "@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query-devtools": "^6.0.3", "bits-ui": "^2.15.4", + "date-fns": "^4.1.0", "tailwindcss": "^4.1.18", }, "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tsconfig/svelte": "^5.0.6", "@types/node": "^24.10.1", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.43.8", "svelte-check": "^4.3.4", "typescript": "~5.9.3", - "vite": "npm:rolldown-vite@7.2.5", + "vite": "^7.2.5", }, }, }, @@ -61,6 +64,8 @@ "@oxc-project/types": ["@oxc-project/types@0.97.0", "", {}, "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.50", "", { "os": "android", "cpu": "arm64" }, "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.50", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w=="], @@ -91,8 +96,14 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.50", "", {}, "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.49.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg=="], + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="], @@ -137,10 +148,10 @@ "@tanstack/svelte-query-devtools": ["@tanstack/svelte-query-devtools@6.0.3", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0", "esm-env": "^1.2.1" }, "peerDependencies": { "@tanstack/svelte-query": "^6.0.12", "svelte": "^5.25.0" } }, "sha512-AHc/vPiUWeMFXKvtrlZot7wIlsIm4z5vd0wDeQUKwE5XTfZODu0no1A4UCLzVnY2WpbBpakIEUMH+PmSMwYXKg=="], - "@tsconfig/svelte": ["@tsconfig/svelte@5.0.6", "", {}, "sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@24.10.7", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ=="], @@ -157,6 +168,10 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -183,6 +198,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -215,6 +232,8 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -229,6 +248,8 @@ "prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="], + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "rolldown": ["rolldown@1.0.0-beta.50", "", { "dependencies": { "@oxc-project/types": "=0.97.0", "@rolldown/pluginutils": "1.0.0-beta.50" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", "@rolldown/binding-darwin-x64": "1.0.0-beta.50", "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A=="], @@ -237,6 +258,10 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -255,6 +280,8 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/nix/homelab/frontend/package.json b/nix/homelab/frontend/package.json index a4b4455..4269159 100644 --- a/nix/homelab/frontend/package.json +++ b/nix/homelab/frontend/package.json @@ -7,14 +7,19 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check ." }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tsconfig/svelte": "^5.0.6", + "@sveltejs/kit": "^2.49.1", + "@sveltejs/adapter-static": "^3.0.10", "@types/node": "^24.10.1", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.43.8", "svelte-check": "^4.3.4", "typescript": "~5.9.3", @@ -29,6 +34,7 @@ "@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query-devtools": "^6.0.3", "bits-ui": "^2.15.4", + "date-fns": "^4.1.0", "tailwindcss": "^4.1.18" } } diff --git a/nix/homelab/frontend/src/app.d.ts b/nix/homelab/frontend/src/app.d.ts new file mode 100644 index 0000000..520c421 --- /dev/null +++ b/nix/homelab/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/nix/homelab/frontend/index.html b/nix/homelab/frontend/src/app.html similarity index 52% rename from nix/homelab/frontend/index.html rename to nix/homelab/frontend/src/app.html index 3ceb58b..39e6457 100644 --- a/nix/homelab/frontend/index.html +++ b/nix/homelab/frontend/src/app.html @@ -4,14 +4,15 @@ - + - Homelab + + %sveltekit.head% - -
- + +
%sveltekit.body%
+ diff --git a/nix/homelab/frontend/src/lib/minecraft/Stats.svelte b/nix/homelab/frontend/src/lib/minecraft/Stats.svelte index 369914c..587a8d4 100644 --- a/nix/homelab/frontend/src/lib/minecraft/Stats.svelte +++ b/nix/homelab/frontend/src/lib/minecraft/Stats.svelte @@ -2,6 +2,12 @@ import { createQuery } from "@tanstack/svelte-query"; import { fetchStats } from "./stats"; import { LoaderCircle } from "@lucide/svelte"; + import { + formatDistance, + formatDistanceToNow, + formatDuration, + intervalToDuration, + } from "date-fns"; const query = createQuery(() => ({ queryKey: ["minecraft-server-stats"], @@ -9,6 +15,21 @@ refetchInterval: 10000, staleTime: 5000, })); + + const formatUptime = () => { + if (!query.data) { + return "--"; + } + const started_at = query.data.uptime.started_at; + const duration = intervalToDuration({ start: started_at, end: new Date() }); + $inspect(duration); + if (!duration.days && (duration.hours ?? 0) < 1) { + return formatDistanceToNow(new Date(query.data.uptime.started_at)); + } + return formatDuration(duration, { + format: ["days", "hours", "minutes"], + }); + }; {#snippet statItem(label: string, value?: string | number)} @@ -21,7 +42,7 @@

Server Stats {#if query.isError} - an error occured fetching server stats {:else if query.isPending} @@ -31,7 +52,7 @@
{@render statItem("Players Online", query.data?.players_online ?? "--")} {@render statItem("Server Status", query.data?.status ?? "--")} - {@render statItem("Uptime", query.data?.uptime ?? "--")} + {@render statItem("Uptime", formatUptime())} {@render statItem("World Size", query.data?.world_size ?? "--")}

diff --git a/nix/homelab/frontend/src/lib/minecraft/stats.ts b/nix/homelab/frontend/src/lib/minecraft/stats.ts index 5dfe15a..4b83d4b 100644 --- a/nix/homelab/frontend/src/lib/minecraft/stats.ts +++ b/nix/homelab/frontend/src/lib/minecraft/stats.ts @@ -2,13 +2,14 @@ type StatsResponse = { status: string; players_online: number; max_players: number; - uptime: string; + uptime: { + seconds: number; + started_at: string; + }; world_size: string; }; export const fetchStats = async () => { - const response = await fetch( - "http://localhost:5173/api/minecraft-server-stats", - ); + const response = await fetch("/api/minecraft-server-stats"); return (await response.json()) as StatsResponse; }; diff --git a/nix/homelab/frontend/src/main.ts b/nix/homelab/frontend/src/main.ts deleted file mode 100644 index 664a057..0000000 --- a/nix/homelab/frontend/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { mount } from 'svelte' -import './app.css' -import App from './App.svelte' - -const app = mount(App, { - target: document.getElementById('app')!, -}) - -export default app diff --git a/nix/homelab/frontend/src/routes/+layout.svelte b/nix/homelab/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..772da13 --- /dev/null +++ b/nix/homelab/frontend/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/nix/homelab/frontend/src/routes/+layout.ts b/nix/homelab/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..83addb7 --- /dev/null +++ b/nix/homelab/frontend/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = false; diff --git a/nix/homelab/frontend/src/App.svelte b/nix/homelab/frontend/src/routes/+page.svelte similarity index 51% rename from nix/homelab/frontend/src/App.svelte rename to nix/homelab/frontend/src/routes/+page.svelte index 73b9fcc..165cf0d 100644 --- a/nix/homelab/frontend/src/App.svelte +++ b/nix/homelab/frontend/src/routes/+page.svelte @@ -1,26 +1,22 @@ -
+
-

Management

+

Management

-
-

Minecraft

+
+

Minecraft

diff --git a/nix/homelab/frontend/src/app.css b/nix/homelab/frontend/src/routes/layout.css similarity index 100% rename from nix/homelab/frontend/src/app.css rename to nix/homelab/frontend/src/routes/layout.css diff --git a/nix/homelab/frontend/public/vite.svg b/nix/homelab/frontend/static/vite.svg similarity index 100% rename from nix/homelab/frontend/public/vite.svg rename to nix/homelab/frontend/static/vite.svg diff --git a/nix/homelab/frontend/svelte.config.js b/nix/homelab/frontend/svelte.config.js index 96b3455..4b05ab3 100644 --- a/nix/homelab/frontend/svelte.config.js +++ b/nix/homelab/frontend/svelte.config.js @@ -1,8 +1,14 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; -/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +/** @type {import("@sveltejs/kit").Config} */ export default { // Consult https://svelte.dev/docs#compile-time-svelte-preprocess // for more information about preprocessors preprocess: vitePreprocess(), -} + kit: { + adapter: adapter({ + fallback: "index.html", + }), + }, +}; diff --git a/nix/homelab/frontend/tsconfig.app.json b/nix/homelab/frontend/tsconfig.app.json deleted file mode 100644 index 31c18cf..0000000 --- a/nix/homelab/frontend/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "types": ["svelte", "vite/client"], - "noEmit": true, - /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable checkJs if you'd like to use dynamic types in JS. - * Note that setting allowJs false does not prevent the use - * of JS in `.svelte` files. - */ - "allowJs": true, - "checkJs": true, - "moduleDetection": "force" - }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/nix/homelab/frontend/tsconfig.json b/nix/homelab/frontend/tsconfig.json index 1ffef60..8ab907f 100644 --- a/nix/homelab/frontend/tsconfig.json +++ b/nix/homelab/frontend/tsconfig.json @@ -1,7 +1,21 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript } + diff --git a/nix/homelab/frontend/tsconfig.node.json b/nix/homelab/frontend/tsconfig.node.json deleted file mode 100644 index 8a67f62..0000000 --- a/nix/homelab/frontend/tsconfig.node.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "types": ["node"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/nix/homelab/frontend/vite.config.ts b/nix/homelab/frontend/vite.config.ts index e851b9b..7fb6cff 100644 --- a/nix/homelab/frontend/vite.config.ts +++ b/nix/homelab/frontend/vite.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ - plugins: [svelte(), tailwindcss()], + plugins: [sveltekit(), tailwindcss()], server: { proxy: { "/api": {