Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 314 additions & 115 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ edition = "2024"
homepage = "https://www.omnect.io/home"
license = "MIT OR Apache-2.0"
repository = "git@github.com:omnect/omnect-ui.git"
version = "1.1.2"
version = "1.3.0"
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ ARG TARGETARCH
ARG OMNECT_UI_BUILD_ARG=""
WORKDIR "/work"

ARG CENTRIFUGO_VERSION=v6.6.0
ARG CENTRIFUGO_VERSION=v6.6.2

RUN curl -sSLf https://centrifugal.dev/install.sh | sh

Expand Down
6 changes: 6 additions & 0 deletions project-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ omnect-ui/
│ │ ├── wasm.rs # WASM FFI bindings
│ │ ├── macros.rs # URL and log macros
│ │ ├── http_helpers.rs # HTTP request utilities
│ │ ├── wifi_psk.rs # WiFi PSK utilities
│ │ ├── commands/ # Custom side-effect commands
│ │ │ ├── mod.rs
│ │ │ └── centrifugo.rs # Centrifugo WebSocket commands
Expand All @@ -164,13 +165,15 @@ omnect-ui/
│ │ │ ├── common.rs # Common shared types
│ │ │ ├── device.rs # Device information types
│ │ │ ├── network.rs # Network configuration types
│ │ │ ├── wifi.rs # WiFi types
│ │ │ ├── ods.rs # ODS-specific DTOs
│ │ │ ├── factory_reset.rs
│ │ │ └── update.rs # Update validation types
│ │ └── update/ # Domain-based event handlers
│ │ ├── mod.rs # Main dispatcher
│ │ ├── auth.rs # Auth event handlers
│ │ ├── ui.rs # UI state handlers
│ │ ├── wifi.rs # WiFi event handlers
│ │ ├── websocket.rs # WebSocket state handlers
│ │ └── device/ # Device domain handlers
│ │ ├── mod.rs
Expand All @@ -187,11 +190,13 @@ omnect-ui/
│ │ │ ├── http_client.rs # Internal HTTP client
│ │ │ ├── keycloak_client.rs
│ │ │ ├── omnect_device_service_client.rs
│ │ │ ├── wifi_commissioning_client.rs
│ │ │ └── services/ # Business logic services
│ │ │ ├── mod.rs
│ │ │ ├── certificate.rs
│ │ │ ├── firmware.rs
│ │ │ ├── network.rs
│ │ │ ├── marker.rs
│ │ │ └── auth/ # Auth logic
│ │ │ ├── mod.rs
│ │ │ ├── authorization.rs # JWT/SSO validation
Expand Down Expand Up @@ -242,6 +247,7 @@ omnect-ui/
│ ├── smoke.spec.ts
│ ├── update.spec.ts
│ ├── version-mismatch.spec.ts
│ ├── wifi.spec.ts
│ └── fixtures/
└── project-context.md # This file
```
Expand Down
8 changes: 4 additions & 4 deletions scripts/build-and-deploy-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,9 @@ if [[ "$DEPLOY" == "true" ]]; then

echo "Copying image to device $DEVICE_HOST..."
if [ -n "$DEVICE_PASS" ]; then
sshpass -p "$DEVICE_PASS" scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/"
sshpass -p "$DEVICE_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/"
else
scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/"
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/"
fi

echo "Loading image on device and restarting container..."
Expand All @@ -203,9 +203,9 @@ if [[ "$DEPLOY" == "true" ]]; then
sudo iotedge system restart"

if [ -n "$DEVICE_PASS" ]; then
sshpass -p "$DEVICE_PASS" ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD"
sshpass -p "$DEVICE_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD"
else
ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD"
fi

echo "Cleaning up local tar file..."
Expand Down
4 changes: 4 additions & 0 deletions src/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false }
serde_repr = { version = "0.1", default-features = false }
serde_valid = { version = "2.0", default-features = false }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
hmac = { version = "0.12", default-features = false }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
sha1 = { version = "0.10", default-features = false }
wasm-bindgen = { version = "0.2", default-features = false }

[target.'cfg(target_arch = "wasm32")'.dependencies]
Expand Down
125 changes: 125 additions & 0 deletions src/app/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub enum DeviceEvent {
RunUpdate {
validate_iothub_connection: bool,
},
FetchInitialHealthcheck,
ReconnectionCheckTick,
ReconnectionTimeout,
NewIpCheckTick,
Expand Down Expand Up @@ -102,12 +103,134 @@ pub enum WebSocketEvent {
Disconnected,
}

/// WiFi management events
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum WifiEvent {
// User actions
CheckAvailability,
Scan,
Connect {
ssid: String,
password: String,
},
Disconnect,
GetStatus,
GetSavedNetworks,
ForgetNetwork {
ssid: String,
},
// Timer ticks (driven by Shell)
ScanPollTick,
ConnectPollTick,
// Responses from HTTP effects
#[serde(skip)]
CheckAvailabilityResponse(Result<WifiAvailability, String>),
#[serde(skip)]
ScanResponse(Result<(), String>),
#[serde(skip)]
ScanResultsResponse(Result<WifiScanResultsApiResponse, String>),
#[serde(skip)]
ConnectResponse(Result<(), String>),
#[serde(skip)]
DisconnectResponse(Result<(), String>),
#[serde(skip)]
StatusResponse(Result<WifiStatusApiResponse, String>),
#[serde(skip)]
SavedNetworksResponse(Result<WifiSavedNetworksApiResponse, String>),
#[serde(skip)]
ForgetNetworkResponse(Result<(), String>),
}

/// API response types for WiFi (match backend JSON shapes)
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WifiScanResultsApiResponse {
pub status: String,
pub state: String,
pub networks: Vec<WifiNetworkApiResponse>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WifiNetworkApiResponse {
pub ssid: String,
pub mac: String,
pub ch: u16,
pub rssi: i16,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WifiStatusApiResponse {
pub status: String,
pub state: String,
pub ssid: Option<String>,
pub ip_address: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WifiSavedNetworksApiResponse {
pub status: String,
pub networks: Vec<WifiSavedNetworkApiResponse>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WifiSavedNetworkApiResponse {
pub ssid: String,
pub flags: String,
}

/// Custom Debug for WifiEvent to redact password
impl fmt::Debug for WifiEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WifiEvent::Connect { ssid, .. } => f
.debug_struct("Connect")
.field("ssid", ssid)
.field("password", &"<redacted>")
.finish(),
WifiEvent::CheckAvailability => write!(f, "CheckAvailability"),
WifiEvent::Scan => write!(f, "Scan"),
WifiEvent::Disconnect => write!(f, "Disconnect"),
WifiEvent::GetStatus => write!(f, "GetStatus"),
WifiEvent::GetSavedNetworks => write!(f, "GetSavedNetworks"),
WifiEvent::ForgetNetwork { ssid } => {
f.debug_struct("ForgetNetwork").field("ssid", ssid).finish()
}
WifiEvent::ScanPollTick => write!(f, "ScanPollTick"),
WifiEvent::ConnectPollTick => write!(f, "ConnectPollTick"),
WifiEvent::CheckAvailabilityResponse(r) => {
f.debug_tuple("CheckAvailabilityResponse").field(r).finish()
}
WifiEvent::ScanResponse(r) => f.debug_tuple("ScanResponse").field(r).finish(),
WifiEvent::ScanResultsResponse(r) => {
f.debug_tuple("ScanResultsResponse").field(r).finish()
}
WifiEvent::ConnectResponse(r) => f.debug_tuple("ConnectResponse").field(r).finish(),
WifiEvent::DisconnectResponse(r) => {
f.debug_tuple("DisconnectResponse").field(r).finish()
}
WifiEvent::StatusResponse(r) => f.debug_tuple("StatusResponse").field(r).finish(),
WifiEvent::SavedNetworksResponse(r) => {
f.debug_tuple("SavedNetworksResponse").field(r).finish()
}
WifiEvent::ForgetNetworkResponse(r) => {
f.debug_tuple("ForgetNetworkResponse").field(r).finish()
}
}
}
}

/// UI action events
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum UiEvent {
ClearError,
ClearSuccess,
SetBrowserHostname(String),
LoadSettings,
SaveSettings(crate::types::TimeoutSettings),
#[serde(skip)]
LoadSettingsResponse(Result<crate::types::TimeoutSettings, String>),
#[serde(skip)]
SaveSettingsResponse(Result<(), String>),
}

/// Main event enum - wraps domain events
Expand All @@ -118,6 +241,7 @@ pub enum Event {
Device(DeviceEvent),
WebSocket(WebSocketEvent),
Ui(UiEvent),
Wifi(WifiEvent),
}

/// Custom Debug implementation for AuthEvent to redact sensitive data
Expand Down Expand Up @@ -180,6 +304,7 @@ impl fmt::Debug for Event {
Event::Device(e) => write!(f, "Device({e:?})"),
Event::WebSocket(e) => write!(f, "WebSocket({e:?})"),
Event::Ui(e) => write!(f, "Ui({e:?})"),
Event::Wifi(e) => write!(f, "Wifi({e:?})"),
}
}
}
57 changes: 57 additions & 0 deletions src/app/src/http_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ pub fn extract_error_message(action: &str, response: &mut Response<Vec<u8>>) ->
}
}

/// Parse JSON from response body regardless of HTTP status.
///
/// Unlike `parse_json_response`, this does not check for 2xx status.
/// Used for endpoints like healthcheck where the body is valid JSON
/// even on error status codes (e.g., 503 for version mismatch).
pub fn parse_json_response_any_status<T: serde::de::DeserializeOwned>(
action: &str,
response: &mut Response<Vec<u8>>,
) -> Result<T, String> {
match response.take_body() {
Some(body) => {
serde_json::from_slice(&body).map_err(|e| format!("{action}: JSON parse error: {e}"))
}
None => Err(format!("{action}: Empty response body")),
}
}

/// Parse JSON from response body.
///
/// Returns error if response is not successful or JSON parsing fails.
Expand Down Expand Up @@ -178,9 +195,49 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crux_http::http::StatusCode;
use crux_http::testing::ResponseBuilder;

fn make_response(status: StatusCode, body: &[u8]) -> Response<Vec<u8>> {
ResponseBuilder::with_status(status)
.body(body.to_vec())
.build()
}

#[test]
fn test_build_url() {
assert_eq!(build_url("/test"), "https://relative/test");
}

#[test]
fn parse_json_response_any_status_parses_on_503() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct Info {
ok: bool,
}

let mut response = make_response(StatusCode::ServiceUnavailable, b"{\"ok\":false}");
let result: Result<Info, String> = parse_json_response_any_status("test", &mut response);
assert_eq!(result.unwrap(), Info { ok: false });
}

#[test]
fn parse_json_response_any_status_parses_on_200() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct Info {
value: u32,
}

let mut response = make_response(StatusCode::Ok, b"{\"value\":42}");
let result: Result<Info, String> = parse_json_response_any_status("test", &mut response);
assert_eq!(result.unwrap(), Info { value: 42 });
}

#[test]
fn parse_json_response_any_status_returns_error_on_invalid_json() {
let mut response = make_response(StatusCode::Ok, b"not json");
let result: Result<String, String> = parse_json_response_any_status("test", &mut response);
assert!(result.is_err());
assert!(result.unwrap_err().contains("JSON parse error"));
}
}
1 change: 1 addition & 0 deletions src/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod macros;
pub mod model;
pub mod types;
pub mod update;
pub mod wifi_psk;

#[cfg(target_arch = "wasm32")]
pub mod wasm;
Expand Down
31 changes: 30 additions & 1 deletion src/app/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ macro_rules! update_field {
pub use crate::http_helpers::{
build_url, check_response_status, extract_error_message, extract_string_response,
handle_auth_error, handle_request_error, is_response_success, map_http_error,
parse_json_response, process_json_response, process_status_response, BASE_URL,
parse_json_response, parse_json_response_any_status, process_json_response,
process_status_response, BASE_URL,
};

/// Macro for unauthenticated POST requests with standard error handling.
Expand Down Expand Up @@ -420,6 +421,34 @@ macro_rules! http_get {
};
}

/// Macro for authenticated GET requests expecting JSON response.
/// Does not set loading state — used for background polling and status checks.
///
/// # Example
/// ```ignore
/// auth_get!(Wifi, WifiEvent, model, "/wifi/status", StatusResponse, "WiFi status",
/// expect_json: WifiStatusApiResponse)
/// ```
#[macro_export]
macro_rules! auth_get {
($domain:ident, $domain_event:ident, $model:expr, $endpoint:expr, $response_event:ident, $action:expr, expect_json: $response_type:ty) => {{
if let Some(token) = &$model.auth_token {
$crate::HttpCmd::get($crate::build_url($endpoint))
.header("Authorization", format!("Bearer {token}"))
.build()
.then_send(|result| {
let event_result: Result<$response_type, String> =
$crate::process_json_response($action, result);
$crate::events::Event::$domain($crate::events::$domain_event::$response_event(
event_result,
))
})
} else {
$crate::handle_auth_error($model, $action)
}
}};
}

/// Silent HTTP GET - no loading state, custom success/error event handlers.
///
/// Used for background polling where failures should not show errors to user.
Expand Down
Loading
Loading