Health checks

Health checks can be enabled for applications which will make an http
request once per minute and color the application icon based on the
response and status code.
This commit is contained in:
Joe Bellus 2022-02-12 01:23:07 -05:00
parent fcc69d2a91
commit bbee8c6460
29 changed files with 772 additions and 58 deletions

334
Cargo.lock generated
View File

@ -86,16 +86,18 @@ dependencies = [
[[package]]
name = "actix-cors"
version = "0.5.4"
version = "0.6.0-beta.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36b133d8026a9f209a9aeeeacd028e7451bcca975f592881b305d37983f303d7"
checksum = "8debd30414af03e9411186aac95e0230b0bb1e91146f48015dfab5c049940223"
dependencies = [
"actix-web 3.3.3",
"actix-service 2.0.2",
"actix-utils 3.0.0",
"actix-web 4.0.0-rc.2",
"derive_more",
"futures-util",
"log",
"once_cell",
"tinyvec",
"smallvec",
]
[[package]]
@ -461,6 +463,23 @@ dependencies = [
"url",
]
[[package]]
name = "actix-web-actors"
version = "4.0.0-beta.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf2ef3eae6001ac2fa6690b2f8b152c00b5b8b2248e3e30f82dd2ec1e941345"
dependencies = [
"actix",
"actix-codec 0.4.2",
"actix-http 3.0.0-rc.1",
"actix-web 4.0.0-rc.2",
"bytes 1.1.0",
"bytestring",
"futures-core",
"pin-project-lite 0.2.8",
"tokio 1.16.1",
]
[[package]]
name = "actix-web-codegen"
version = "0.4.0"
@ -852,6 +871,22 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2df960f5d869b2dd8532793fde43eb5427cceb126c929747a26823ab0eeb536"
[[package]]
name = "core-foundation"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
[[package]]
name = "cpufeatures"
version = "0.2.1"
@ -996,6 +1031,15 @@ dependencies = [
"syn",
]
[[package]]
name = "fastrand"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf"
dependencies = [
"instant",
]
[[package]]
name = "firestorm"
version = "0.5.0"
@ -1032,6 +1076,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.0.1"
@ -1308,6 +1367,17 @@ dependencies = [
"itoa 1.0.1",
]
[[package]]
name = "http-body"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6"
dependencies = [
"bytes 1.1.0",
"http",
"pin-project-lite 0.2.8",
]
[[package]]
name = "httparse"
version = "1.5.1"
@ -1320,6 +1390,43 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "hyper"
version = "0.14.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ec3e62bdc98a2f0393a5048e4c30ef659440ea6e0e572965103e72bd836f55"
dependencies = [
"bytes 1.1.0",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.11",
"http",
"http-body",
"httparse",
"httpdate",
"itoa 0.4.8",
"pin-project-lite 0.2.8",
"socket2 0.4.4",
"tokio 1.16.1",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes 1.1.0",
"hyper",
"native-tls",
"tokio 1.16.1",
"tokio-native-tls",
]
[[package]]
name = "idna"
version = "0.2.3"
@ -1368,9 +1475,15 @@ dependencies = [
"socket2 0.3.19",
"widestring",
"winapi 0.3.9",
"winreg",
"winreg 0.6.2",
]
[[package]]
name = "ipnet"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9"
[[package]]
name = "itertools"
version = "0.10.3"
@ -1677,6 +1790,24 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "native-tls"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "net2"
version = "0.2.37"
@ -1780,6 +1911,39 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95"
dependencies = [
"bitflags",
"cfg-if 1.0.0",
"foreign-types",
"libc",
"once_cell",
"openssl-sys",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb"
dependencies = [
"autocfg",
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ouroboros"
version = "0.14.0"
@ -2099,6 +2263,51 @@ version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "reqwest"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525"
dependencies = [
"base64",
"bytes 1.1.0",
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.3.11",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"lazy_static",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite 0.2.8",
"serde",
"serde_json",
"serde_urlencoded",
"tokio 1.16.1",
"tokio-native-tls",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg 0.7.0",
]
[[package]]
name = "resolv-conf"
version = "0.7.0"
@ -2222,6 +2431,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75"
dependencies = [
"lazy_static",
"winapi 0.3.9",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
@ -2324,6 +2543,29 @@ dependencies = [
"syn",
]
[[package]]
name = "security-framework"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "0.9.0"
@ -2709,6 +2951,20 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "tempfile"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
dependencies = [
"cfg-if 1.0.0",
"fastrand",
"libc",
"redox_syscall",
"remove_dir_all",
"winapi 0.3.9",
]
[[package]]
name = "thiserror"
version = "1.0.30"
@ -2864,9 +3120,31 @@ dependencies = [
"parking_lot",
"pin-project-lite 0.2.8",
"signal-hook-registry",
"tokio-macros",
"winapi 0.3.9",
]
[[package]]
name = "tokio-macros"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b"
dependencies = [
"native-tls",
"tokio 1.16.1",
]
[[package]]
name = "tokio-rustls"
version = "0.22.0"
@ -2917,6 +3195,12 @@ dependencies = [
"tokio 1.16.1",
]
[[package]]
name = "tower-service"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6"
[[package]]
name = "tracing"
version = "0.1.30"
@ -3074,6 +3358,12 @@ dependencies = [
"trust-dns-proto",
]
[[package]]
name = "try-lock"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
[[package]]
name = "typenum"
version = "1.15.0"
@ -3158,6 +3448,7 @@ dependencies = [
"actix-cors",
"actix-rt 2.6.0",
"actix-web 4.0.0-rc.2",
"actix-web-actors",
"actix-web-httpauth",
"base64",
"bcrypt",
@ -3166,11 +3457,13 @@ dependencies = [
"jsonwebtoken",
"mime_guess",
"rand 0.8.4",
"reqwest",
"rust-embed",
"sea-orm",
"serde",
"serde_json",
"sqlx",
"tokio 1.16.1",
"tracing",
"tracing-actix-web",
"tracing-subscriber",
@ -3207,6 +3500,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
dependencies = [
"log",
"try-lock",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
@ -3244,6 +3547,18 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb6ec270a31b1d3c7e266b999739109abce8b6c87e4b31fcfcd788b65267395"
dependencies = [
"cfg-if 1.0.0",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.79"
@ -3360,6 +3675,15 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "winreg"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "ws2_32-sys"
version = "0.2.1"

View File

@ -11,7 +11,8 @@ tracing = "0.1.30"
tracing-unwrap = "0.9.2"
tracing-subscriber = { version = "0.3.8", features = ["fmt"] }
actix = "0.12.0"
actix-cors = "0.5.4"
actix-cors = "0.6.0-beta.10"
actix-web-actors = "4.0.0-beta.6"
chrono = { version = "0.4.19", features = ["serde"] }
serde = { version = "1.0.136", features= [ "derive" ] }
serde_json = "1.0.78"
@ -25,8 +26,10 @@ bcrypt = "0.10.1"
actix-web-httpauth = "0.6.0-beta.7"
jsonwebtoken = "8.0.1"
rand = "0.8.4"
tokio = { verison = "1", features=["full"] }
base64 = "0.13.0"
sqlx = { version = "^0.5", features=["sqlite", "migrate"] }
reqwest = "0.11.9"
[target.'cfg(all(target_env = "musl", target_pointer_width = "64"))'.dependencies.jemallocator]
version = "0.3"

View File

@ -1,6 +1,6 @@
FROM alpine:latest
COPY target/x86_64-unknown-linux-musl/release/vade /app/vade
EXPOSE 8080
EXPOSE 8088
WORKDIR app
RUN touch data.db
CMD ["./vade"]

View File

@ -6,7 +6,9 @@ CREATE TABLE application (
description TEXT,
active Boolean NOT NULL DEFAULT 1,
glyph TEXT,
application_category_id INTEGER
application_category_id INTEGER,
alive Boolean NOT NULL DEFAULT 1,
enable_healthcheck Boolean NOT NULL DEFAULT 0
);
CREATE TABLE application_category (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,

View File

@ -3,11 +3,50 @@ use tracing::instrument;
use crate::api::api_prelude::*;
use crate::error::{Error, Result};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiApplication {
pub id: i32,
pub app_name: String,
pub url: String,
pub description: Option<String>,
pub active: bool,
pub glyph: Option<String>,
pub application_category_id: Option<i32>,
pub enable_healthcheck: bool,
pub healthcheck_status: Option<bool>,
}
impl From<application::Model> for ApiApplication {
fn from(model: application::Model) -> Self {
Self {
id: model.id,
app_name: model.app_name,
url: model.url,
description: model.description,
active: model.active,
glyph: model.glyph,
application_category_id: model.application_category_id,
enable_healthcheck: model.enable_healthcheck,
healthcheck_status: None,
}
}
}
#[instrument]
#[get("")]
pub async fn list_applications(state: web::Data<AppState>) -> Result<HttpResponse> {
let apps: Vec<application::Model> = Application::find().all(&state.db).await.unwrap();
let count = apps.len();
let mut apps = apps
.into_iter()
.map(|app| app.into())
.collect::<Vec<ApiApplication>>();
for app in apps.iter_mut() {
app.healthcheck_status = state.healthcheck_status.lock().await.get(&app.id).cloned();
}
Ok(HttpResponse::Ok().json(ListObjects::new(apps, count)))
}
@ -25,6 +64,7 @@ pub async fn new_application(
active: Set(data.0.active),
glyph: Set(data.0.glyph),
application_category_id: Set(data.0.application_category_id),
enable_healthcheck: Set(data.0.enable_healthcheck),
};
let app = model.insert(&state.db).await?;
Ok(HttpResponse::Ok().json(app))
@ -65,6 +105,7 @@ pub async fn update_applications(
url: Set(data.url),
application_category_id: Set(data.application_category_id),
glyph: Set(data.glyph),
enable_healthcheck: Set(data.enable_healthcheck),
};
let model = ret.update(&state.db).await?;
Ok(HttpResponse::Ok().json(model))
@ -168,6 +209,7 @@ mod tests {
description: Some("Some Application".into()),
active: true,
application_category_id: None,
enable_healthcheck: false,
};
let state = setup_state().await?;

View File

@ -78,6 +78,8 @@ mod api_prelude {
#[cfg(test)]
pub mod test_prelude {
use std::collections::HashMap;
pub use super::ListObjects;
use crate::auth;
pub use crate::entity::*;
@ -96,6 +98,7 @@ pub mod test_prelude {
entity::prelude::*, entity::*, tests_cfg::*, DatabaseBackend, MockDatabase, MockExecResult,
Transaction,
};
use tokio::sync::Mutex;
/// Sets up a testing state with an in-memory database and creates the scheme.
pub async fn setup_state() -> Result<actix_web::web::Data<AppState>> {
@ -118,7 +121,10 @@ pub mod test_prelude {
auth::generate_secret(&db).await?;
Ok(actix_web::web::Data::new(AppState { db }))
Ok(actix_web::web::Data::new(AppState {
db,
healthcheck_status: Mutex::new(HashMap::new()),
}))
}
}

View File

@ -14,6 +14,7 @@ pub struct Model {
pub active: bool,
pub glyph: Option<String>,
pub application_category_id: Option<i32>,
pub enable_healthcheck: bool,
}
#[derive(Copy, Clone, Debug, EnumIter)]
@ -50,6 +51,7 @@ impl Default for Model {
active: true,
glyph: Default::default(),
application_category_id: Default::default(),
enable_healthcheck: false,
}
}
}

View File

@ -62,7 +62,7 @@ impl actix_web::error::ResponseError for Error {
fn status_code(&self) -> actix_web::http::StatusCode {
match self.code {
ErrorCode::UnAuthorized => StatusCode::UNAUTHORIZED,
ErrorCode::NoSetup => StatusCode::UPGRADE_REQUIRED,
ErrorCode::NoSetup => StatusCode::OK,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}

81
src/events.rs Normal file
View File

@ -0,0 +1,81 @@
use actix::*;
use rand::{self, rngs::ThreadRng, Rng};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type ID = i64;
#[derive(Debug, Clone)]
pub struct Session {
pub addr: Recipient<Event>,
}
#[derive(Message)]
#[rtype(result = "ID")]
pub struct Connect {
pub addr: Recipient<Event>,
}
#[derive(Message)]
#[rtype(result = "()")]
pub struct Disconnect {
pub id: ID,
}
#[derive(Message, Clone, Debug, Serialize, Deserialize)]
#[rtype(result = "()")]
pub enum Event {
HealthcheckChange { app_id: i32, alive: bool },
}
#[derive(Default)]
pub struct EventBroker {
rng: ThreadRng,
sessions: HashMap<ID, Session>,
}
impl Supervised for EventBroker {}
impl SystemService for EventBroker {}
impl Actor for EventBroker {
type Context = Context<Self>;
fn started(&mut self, _: &mut Self::Context) {
tracing::info!("EventBroker - Started");
}
fn stopped(&mut self, _: &mut Self::Context) {
tracing::error!("EventBroker - Shutdown");
}
}
impl Handler<Connect> for EventBroker {
type Result = ID;
fn handle(&mut self, msg: Connect, _: &mut Context<Self>) -> Self::Result {
tracing::info!("Session connected");
let id = self.rng.gen::<ID>();
self.sessions.insert(id, Session { addr: msg.addr });
id
}
}
impl Handler<Disconnect> for EventBroker {
type Result = ();
fn handle(&mut self, msg: Disconnect, _: &mut Context<Self>) {
tracing::info!("Session disconnected");
self.sessions.remove(&msg.id);
}
}
impl Handler<Event> for EventBroker {
type Result = ();
fn handle(&mut self, msg: Event, _ctx: &mut Self::Context) -> Self::Result {
tracing::info!("Event received");
for (_, ses) in self.sessions.iter() {
let _ = ses.addr.do_send(msg.clone());
}
}
}

View File

@ -13,6 +13,11 @@ axios.interceptors.request.use(function (config) {
return config;
});
axios.defaults.baseURL = process.env.NODE_ENV === 'development'
? 'http://localhost:8088'
: '';
axios.interceptors.response.use(function (response) {
return response;
}, function (error) {

View File

@ -1,8 +1,10 @@
use std::path::Path;
use std::{collections::HashMap, path::Path};
use actix::SystemService;
use actix_web::{get, web, App, HttpResponse, HttpServer};
use rust_embed::RustEmbed;
use sea_orm::{Database, DatabaseConnection};
use sea_orm::{prelude::*, Database};
use tokio::sync::Mutex;
use tracing::{info, instrument};
use tracing_subscriber::prelude::*;
@ -10,6 +12,8 @@ mod api;
mod auth;
mod entity;
mod error;
mod events;
mod socket_session;
#[cfg(all(target_env = "musl", target_pointer_width = "64"))]
#[global_allocator]
@ -18,32 +22,74 @@ static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
#[derive(Debug)]
pub struct AppState {
pub db: DatabaseConnection,
pub healthcheck_status: Mutex<HashMap<i32, bool>>,
}
#[actix_web::main]
#[actix_rt::main]
async fn main() {
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::Layer::new()
.pretty()
.with_writer(std::io::stdout)
.with_ansi(true)
.with_filter(tracing_subscriber::filter::LevelFilter::TRACE),
.with_filter(tracing_subscriber::filter::LevelFilter::INFO),
);
tracing::subscriber::set_global_default(subscriber).expect("Unable to set a global collector");
let db = setup_database().await.unwrap();
let state = web::Data::new(AppState { db });
let state = web::Data::new(AppState {
db,
healthcheck_status: Mutex::new(HashMap::new()),
});
info!("Starting http server on 8080");
let st = state.clone();
actix_rt::spawn(async move {
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
let apps: Vec<entity::application::Model> = entity::application::Entity::find()
.filter(entity::application::Column::EnableHealthcheck.eq(true))
.all(&st.db)
.await
.unwrap();
for app in apps {
match reqwest::get(app.url).await {
Ok(res) if res.status() == 200 => {
st.healthcheck_status.lock().await.insert(app.id, true);
let _ = events::EventBroker::from_registry()
.send(events::Event::HealthcheckChange {
app_id: app.id,
alive: true,
})
.await;
}
_ => {
st.healthcheck_status.lock().await.insert(app.id, false);
let _ = events::EventBroker::from_registry()
.send(events::Event::HealthcheckChange {
app_id: app.id,
alive: false,
})
.await;
}
}
}
}
});
HttpServer::new(move || {
let cors = actix_cors::Cors::permissive();
App::new()
.app_data(state.clone())
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(cors)
.service(web::resource("/events/{token}").to(socket_session::event_session_index))
.service(api::routes())
.service(dist)
})
.bind("0.0.0.0:8080")
.bind("0.0.0.0:8088")
.unwrap()
.run()
.await

79
src/socket_session.rs Normal file
View File

@ -0,0 +1,79 @@
use super::events::{Connect, Disconnect, Event, EventBroker, ID};
use crate::auth::AuthClaims;
use actix::*;
use actix_web::{
web::{self, Data},
Error, HttpRequest, HttpResponse,
};
use actix_web_actors::ws;
use jsonwebtoken::{decode, DecodingKey, Validation};
pub struct EventSession {
id: ID,
}
impl Actor for EventSession {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, ctx: &mut Self::Context) {
let addr = ctx.address();
EventBroker::from_registry()
.send(Connect {
addr: addr.recipient(),
})
.into_actor(self)
.then(|res, act, ctx| {
match res {
Ok(res) => act.id = res,
// something is wrong. Burn it with fire.
_ => ctx.stop(),
}
fut::ready(())
})
.wait(ctx);
}
fn stopping(&mut self, _: &mut Self::Context) -> Running {
// notify suprivisor we are leavin
EventBroker::from_registry().do_send(Disconnect { id: self.id });
Running::Stop
}
}
impl Handler<Event> for EventSession {
type Result = ();
fn handle(&mut self, msg: Event, ctx: &mut Self::Context) -> Self::Result {
let data = serde_json::to_string(&msg).unwrap_or_default();
ctx.text(data);
}
}
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for EventSession {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
Ok(ws::Message::Text(text)) => ctx.text(text),
Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
_ => (),
}
}
}
pub async fn event_session_index(
req: HttpRequest,
stream: web::Payload,
state: Data<crate::AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, Error> {
let secret = crate::auth::get_secret(&state.db).await?;
let decoded = decode::<AuthClaims>(
&path,
&DecodingKey::from_secret(&secret),
&Validation::default(),
);
match decoded {
Ok(_) => ws::start(EventSession { id: 0 }, &req, stream),
Err(e) => Err(actix_web::error::ErrorUnauthorized(e.to_string())),
}
}

View File

@ -30,7 +30,6 @@ label {
color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
* {

View File

@ -9,7 +9,7 @@
<template #actions>
<btn @click="delCategory" label="Delete" danger v-if="!!this.category.id" />
<btn @click="close" label="Cancel" />
<btn @click="submit" :label="saveLabel" />
<btn primary @click="submit" :label="saveLabel" />
</template>
</modal>
</template>

View File

@ -13,12 +13,13 @@
<text-field v-model="app.description" label="Description" />
<text-field v-model="app.url" label="URL" />
<icon-picker v-model="app.glyph" />
<switch-field v-model="app.enableHealthcheck" label="Health check" />
</form>
</template>
<template #actions>
<btn @click="delApp" label="Delete" danger v-if="!!this.app.id" />
<btn @click="close" label="Cancel" />
<btn :label="saveLabel" type="button" @click="save" />
<btn primary :label="saveLabel" type="button" @click="save" />
</template>
</modal>
</template>
@ -29,9 +30,10 @@ import IconPicker from "./IconPicker.vue";
import axios from "axios";
import Btn from "./Button.vue";
import SelectField from "./SelectField.vue";
import SwitchField from './Switch.vue';
export default {
components: { TextField, Modal, IconPicker, Btn, SelectField },
components: { TextField, Modal, IconPicker, Btn, SelectField, SwitchField },
props: ["open", "mode", "data", "categories"],
watch: {
data: function (next) {

View File

@ -1,5 +1,5 @@
<template>
<div class="tile" @click="click">
<div :class="{tile: true, alive: appData.healthcheckStatus === true, dead: appData.healthcheckStatus === false}" @click="click">
<font-awesome-icon :icon="appData.glyph" size="2x" />
<div class="label">
<div class="title">{{appData.appName}}</div>
@ -43,6 +43,13 @@ svg {
color: #fff;
margin-right: 20px;
}
.tile.alive svg {
color: #009900;
}
.tile.dead svg {
color: #900;
}
.title {
color: #fff;
font-weight: bold;

View File

@ -9,7 +9,7 @@
<template #actions>
<btn @click="delCategory" label="Delete" danger v-if="!!this.category.id" />
<btn @click="close" label="Cancel" />
<btn @click="submit" :label="saveLabel" />
<btn primary @click="submit" :label="saveLabel" />
</template>
</modal>
</template>

View File

@ -15,7 +15,7 @@
<template #actions>
<btn @click="delBookmark" label="Delete" danger v-if="!!this.bookmark.id" />
<btn @click="close" label="Cancel" />
<btn :label="saveLabel" type="button" @click="save" />
<btn primary :label="saveLabel" type="button" @click="save" />
</template>
</modal>
</template>

View File

@ -1,5 +1,5 @@
<template>
<button :class="{danger: danger}">{{label}}</button>
<button :class="{danger: danger, primary: primary}">{{label}}</button>
</template>
<script>
@ -7,6 +7,7 @@ export default {
props: {
label: String,
danger: Boolean,
primary: Boolean,
},
};
</script>
@ -16,26 +17,33 @@ button {
cursor: pointer;
transition: all 0.2s;
padding: 10px 18px;
background: #333;
color: #fff;
font-weight: 500;
border: 2px solid #222;
background: #333;
box-shadow: none;
}
button.danger {
color: #cc0000;
background-color: #200;
border: 2px solid #200;
border: none;
background: #21242B;
box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px;
}
button:hover {
transition: all 0.2s;
background: #444;
box-shadow: rgba(0, 0, 0, 0.42) 0px 1px 3px, rgba(0, 0, 0, 0.64) 0px 1px 2px;
}
button.danger {
background: #41141B;
}
button.danger:hover {
background: #400;
background: #61141B;
}
button.primary {
background: #21244B;
}
button.primary:hover {
background: #21246B;
}
</style>

View File

@ -14,10 +14,15 @@
}
label {
margin-right: 15px;
font-weight: bold;
text-transform: uppercase;
font-weight: 500;
line-height: 35px;
text-align: right;
color: #999;
width: 150px;
}
label::after {
content: ':'
}
.form-field + .form-field {
margin-top: 15px;
}

View File

@ -12,6 +12,7 @@
</form-field>
<div class="icons">
<font-awesome-icon
:class="{selected: modelValue === icon}"
:icon="icon"
size="2x"
v-for="icon in items"
@ -1039,7 +1040,7 @@ export default {
}
return icons
.filter((i) => i.includes(this.modelValue.toString().toLowerCase()))
.slice(0, 21);
.slice(0, 18);
},
},
methods: {
@ -1061,19 +1062,35 @@ export default {
input {
color: #fff;
background-color: #121212;
background-color: transparent;
outline: none;
display: block;
border: 1px solid #000;
border: none;
border-bottom: 1px solid #555;
font-weight: bold;
padding: 8px 15px;
flex: 1;
}
input:focus {
transition: all 0.2s;
border-bottom: 1px solid #336699;
}
.icons {
margin-bottom: 15px;
height: 130px;
background: #21242b;
padding: 15px;
display: grid;
width: 100%;
border-radius: 10px;
width: calc(100% - 30px);
margin-top: 15px;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(6, 1fr);
justify-items: center;
gap: 10px;
}
.icons .selected {
color: #336699;
}
</style>

View File

@ -80,7 +80,7 @@ export default {
height 300ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1), 0 6px 6px rgba(0, 0, 0, 0.16);
background: #333;
background: #282c33;
display: flex;
flex-direction: column;
align-items: stretch;
@ -96,7 +96,7 @@ export default {
}
.modal .actions {
background: #3c3c3c;
background: #21242b;
border-top: 1px solid #2c2c2c;
display: flex;
flex-direction: row;

View File

@ -7,7 +7,7 @@
<style>
.panel {
border-radius: 5px;
background: #333;
background: #21242b;
padding: 25px;
box-shadow: rgb(0, 0, 0) 0px 20px 30px -10px;
display: flex;

View File

@ -30,7 +30,8 @@ export default {
select {
flex: 1;
display: block;
color: #ccc;
color: #fff;
font-weight: bold;
line-height: 1.3;
padding: 0.6em 1.4em 0.5em 0.8em;
width: 100%;
@ -38,10 +39,12 @@ select {
box-sizing: border-box;
margin: 0;
border: none;
border-bottom: 1px solid #555;
overflow: hideden;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: #121212;
background-color: transparent;
}
select::-ms-expand {
display: none;

View File

@ -0,0 +1,49 @@
<template>
<form-field>
<label>{{label}}</label>
<div :class="{switch: true, active: modelValue}" @click="handleClick">
<div class="indicator"></div>
</div>
</form-field>
</template>
<script>
import FormField from './FormField.vue'
export default {
props: ["label", "modelValue"],
components: { FormField },
methods: {
handleClick() {
this.$emit("update:modelValue", !this.modelValue)
}
}
}
</script>
<style scoped>
.switch {
background: #21242b;
border-radius: 15px;
height: 36px;
width: 75px;
}
.indicator {
width: 26px;
height: 26px;
border-radius: 13px;
background: white;
position: relative;
top: 4px;
left: 5px;
background: #282c33;
transition: all 0.2s;
}
.switch.active .indicator {
transition: all 0.2s;
top: 4px;
left: 45px;
background: #336699;
}
</style>

View File

@ -29,11 +29,19 @@ export default {
<style scoped>
input {
transition: all 0.2s;
color: #fff;
background-color: #121212;
background-color: transparent;
font-weight: bold;
outline: none;
border: 1px solid #000;
border: none;
border-bottom: 1px solid #555;
overflow: hidden;
flex: 1;
padding: 8px 15px;
}
input:focus {
transition: all 0.2s;
border-bottom: 1px solid #336699;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="dashboard">
<div :class="{dashboard: true, 'edit-mode': editMode}">
<div class="editmode-tiles">
<new-item-tile title="New Application" v-if="editMode" @click="openNewApp" />
<new-item-tile title="New App Category" v-if="editMode" @click="openNewAppCat" />
@ -44,7 +44,7 @@
</div>
</div>
<div class="container" v-for="cat in applicationCategories" :key="cat.id">
<h1 @click="appCatClicked(cat)">
<h1 @click="appCatClicked(cat)" class="editable">
<font-awesome-icon v-if="cat.glyph" :icon="cat.glyph" />
{{cat.categoryName}}
</h1>
@ -58,7 +58,7 @@
</div>
</div>
<div class="container">
<div class="container" v-if="bookmarks.length">
<h1 class="bookmark">BOOKMARKS</h1>
<div class="bookmark-container">
<div class="bookmark-category">
@ -71,7 +71,7 @@
/>
</div>
<div class="bookmark-category" v-for="cat in bookmarkCategories" :key="cat.id">
<h2 @click="bookmarkCatClicked(cat)">
<h2 @click="bookmarkCatClicked(cat)" class="editable">
<font-awesome-icon v-if="cat.glyph" :icon="cat.glyph" />
{{cat.categoryName}}
</h2>
@ -102,7 +102,23 @@ import BookmarkCategoryModal from "../components/BookmarkCategoryModal.vue";
export default {
name: "Dashboard",
unmounted() {
this.connection = null;
},
methods: {
connect_ws() {
const token = localStorage.getItem("token")
this.connection = new WebSocket("ws://localhost:8088/events/" + encodeURIComponent(token));
this.connection.onmessage = (event) => {
const data = JSON.parse(event.data);
const { app_id, alive } = data.HealthcheckChange;
const idx = this.applications.findIndex((app) => app.id == app_id);
this.applications[idx].healthcheckStatus = alive;
}
this.connection.onclose = () => {
setTimeout(() => this.connect_ws(), 1000);
};
},
appsForCategory(catId) {
return this.applications.filter((i) => i.applicationCategoryId === catId);
},
@ -179,12 +195,16 @@ export default {
this.bookmarkCategories = (
await axios.get("/api/bookmark_categories")
).data.items;
this.editApp = {};
this.newAppOpen = false;
this.editAppCat = {};
this.appOpen = false;
this.appCatOpen = false;
this.editBookmark = {};
this.editBookmarkCat = {};
this.bookmarkCatOpen = false;
this.bookmarkOpen = false;
this.editAppCat = {};
},
},
components: {
@ -212,9 +232,11 @@ export default {
editBookmarkCat: {},
bookmarks: [],
bookmarkCategories: [],
connection: null,
};
},
async mounted() {
this.connect_ws();
this.reload();
},
};
@ -237,6 +259,10 @@ h2 {
font-weight: bold;
margin-bottom: 5px;
}
.edit-mode .editable {
cursor: pointer;
}
h2 svg {
margin-right: 5px;
}

View File

@ -47,7 +47,7 @@ export default {
}
input {
background: #333;
font-size: 40px;
font-size: 20px;
color: #fff;
background: #121212;
font-weight: bold;

View File

@ -3,7 +3,7 @@
<panel>
<h1>Setup a password</h1>
<text-field v-model="password" password />
<btn label="Setup" @click="setup" />
<btn primary label="Setup" @click="setup" />
</panel>
</div>
</template>