diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 075fdd3..1900b4f 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,6 +4,7 @@ members = [ "proxy", "storage", "subscription", + "sla", "types", ] diff --git a/contracts/proxy/Cargo.toml b/contracts/proxy/Cargo.toml index b8786a1..507e920 100644 --- a/contracts/proxy/Cargo.toml +++ b/contracts/proxy/Cargo.toml @@ -6,6 +6,7 @@ authors = ["SubTrackr Team"] description = "Upgradeable proxy for SubTrackr (Soroban)" [lib] +path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [dependencies] diff --git a/contracts/sla/Cargo.toml b/contracts/sla/Cargo.toml new file mode 100644 index 0000000..239f371 --- /dev/null +++ b/contracts/sla/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "subtrackr-sla" +version = "0.2.0" +edition = "2021" +authors = ["SubTrackr Team"] +description = "SLA monitoring contract for SubTrackr (Soroban)" + +[lib] +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/sla/src/lib.rs b/contracts/sla/src/lib.rs new file mode 100644 index 0000000..5c80771 --- /dev/null +++ b/contracts/sla/src/lib.rs @@ -0,0 +1,499 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, + Address, Env, String, Symbol, Vec, +}; + +const DEFAULT_UPTIME_TARGET_BPS: u32 = 9_900; +const DEFAULT_MEASUREMENT_INTERVAL_SECS: u64 = 604_800; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum AvailabilityState { + Healthy, + PartialOutage, + FullOutage, + Maintenance, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SLAConfig { + pub uptime_target_bps: u32, + pub measurement_interval: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AvailabilitySample { + pub id: u64, + pub merchant_id: Address, + pub timestamp: u64, + pub duration_secs: u64, + pub state: AvailabilityState, + pub note: String, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SLABreach { + pub id: u64, + pub merchant_id: Address, + pub detected_at: u64, + pub uptime_target_bps: u32, + pub uptime_bps: u32, + pub measurement_interval: u64, + pub observed_seconds: u64, + pub downtime_seconds: u64, + pub partial_outage_seconds: u64, + pub maintenance_seconds: u64, + pub credit_amount: i128, + pub resolved_at: u64, + pub acknowledged: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SLAStatus { + pub merchant_id: Address, + pub uptime_target_bps: u32, + pub measurement_interval: u64, + pub observed_seconds: u64, + pub uptime_bps: u32, + pub downtime_seconds: u64, + pub partial_outage_seconds: u64, + pub maintenance_seconds: u64, + pub breach_count: u64, + pub active_breach_id: u64, + pub credit_balance: i128, + pub compliant: bool, + pub last_updated_at: u64, + pub last_breach_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum DataKey { + Admin, + Config(Address), + Status(Address), + SampleCount(Address), + Sample(Address, u64), + BreachCount, + Breach(u64), + ActiveBreach(Address), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Metrics { + pub observed_seconds: u64, + pub downtime_seconds: u64, + pub partial_outage_seconds: u64, + pub maintenance_seconds: u64, + pub uptime_bps: u32, +} + +fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("Admin not set") +} + +fn read_config(env: &Env, merchant_id: &Address) -> Option { + env.storage().instance().get(&DataKey::Config(merchant_id.clone())) +} + +fn read_breach(env: &Env, breach_id: u64) -> Option { + env.storage().instance().get(&DataKey::Breach(breach_id)) +} + +fn calculate_impact(sample: &AvailabilitySample) -> (u64, u64, u64) { + match sample.state { + AvailabilityState::Healthy => (0, 0, 0), + AvailabilityState::Maintenance => (0, 0, sample.duration_secs), + AvailabilityState::PartialOutage => (sample.duration_secs / 2, sample.duration_secs, 0), + AvailabilityState::FullOutage => (sample.duration_secs, 0, 0), + } +} + +fn calculate_credit_amount(status: &SLAStatus) -> i128 { + if status.uptime_bps >= status.uptime_target_bps { + return 0; + } + + let deficit_bps = (status.uptime_target_bps - status.uptime_bps) as i128; + let severity = (status.downtime_seconds + status.partial_outage_seconds / 2) as i128; + let window = status.measurement_interval.max(1) as i128; + ((deficit_bps * severity * 100) / window).max(1) +} + +fn merchant_samples(env: &Env, merchant_id: &Address) -> Vec { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::SampleCount(merchant_id.clone())) + .unwrap_or(0); + let mut samples = Vec::new(env); + let mut i = 1; + while i <= count { + if let Some(sample) = env + .storage() + .instance() + .get(&DataKey::Sample(merchant_id.clone(), i)) + { + samples.push_back(sample); + } + i += 1; + } + samples +} + +fn merchant_breaches(env: &Env, merchant_id: &Address) -> Vec { + let count: u64 = env.storage().instance().get(&DataKey::BreachCount).unwrap_or(0); + let mut breaches = Vec::new(env); + let mut i = 1; + while i <= count { + let breach: Option = env.storage().instance().get(&DataKey::Breach(i)); + if let Some(breach) = breach { + if breach.merchant_id == *merchant_id { + breaches.push_back(breach); + } + } + i += 1; + } + breaches +} + +fn calculate_metrics(env: &Env, config: &SLAConfig, merchant_id: &Address) -> Metrics { + let now = env.ledger().timestamp(); + let window_start = now.saturating_sub(config.measurement_interval); + let samples = merchant_samples(env, merchant_id); + + let mut observed_seconds = 0u64; + let mut downtime_seconds = 0u64; + let mut partial_outage_seconds = 0u64; + let mut maintenance_seconds = 0u64; + + for sample in samples.iter() { + let sample_start = sample.timestamp; + let sample_end = sample.timestamp.saturating_add(sample.duration_secs); + if sample_end <= window_start || sample_start >= now { + continue; + } + + let overlap_start = sample_start.max(window_start); + let overlap_end = sample_end.min(now); + if overlap_end <= overlap_start { + continue; + } + + let overlap = overlap_end - overlap_start; + let overlap_sample = AvailabilitySample { + id: sample.id, + merchant_id: sample.merchant_id.clone(), + timestamp: overlap_start, + duration_secs: overlap, + state: sample.state.clone(), + note: sample.note.clone(), + }; + let (downtime, partial, maintenance) = calculate_impact(&overlap_sample); + observed_seconds += overlap; + downtime_seconds += downtime; + partial_outage_seconds += partial; + maintenance_seconds += maintenance; + } + + let uptime_bps = if observed_seconds == 0 { + 10_000 + } else { + let downtime_bps = ((downtime_seconds as u128 * 10_000) / observed_seconds as u128) as u32; + 10_000u32.saturating_sub(downtime_bps) + }; + + Metrics { + observed_seconds, + downtime_seconds, + partial_outage_seconds, + maintenance_seconds, + uptime_bps, + } +} + +fn upsert_status(env: &Env, merchant_id: &Address, config: &SLAConfig) -> (SLAStatus, Option) { + let metrics = calculate_metrics(env, config, merchant_id); + let now = env.ledger().timestamp(); + let breaches = merchant_breaches(env, merchant_id); + let mut active_breach: Option = None; + for breach in breaches.iter().rev() { + if breach.resolved_at == 0 { + active_breach = Some(breach.clone()); + break; + } + } + + let mut status = SLAStatus { + merchant_id: merchant_id.clone(), + uptime_target_bps: config.uptime_target_bps, + measurement_interval: config.measurement_interval, + observed_seconds: metrics.observed_seconds, + uptime_bps: metrics.uptime_bps, + downtime_seconds: metrics.downtime_seconds, + partial_outage_seconds: metrics.partial_outage_seconds, + maintenance_seconds: metrics.maintenance_seconds, + breach_count: breaches.len() as u64, + active_breach_id: active_breach.as_ref().map(|breach| breach.id).unwrap_or(0), + credit_balance: breaches.iter().map(|breach| breach.credit_amount).sum(), + compliant: metrics.uptime_bps >= config.uptime_target_bps, + last_updated_at: now, + last_breach_at: breaches.iter().map(|breach| breach.detected_at).max().unwrap_or(0), + }; + + if status.compliant { + if let Some(open_breach) = active_breach { + let mut resolved = open_breach.clone(); + resolved.resolved_at = now; + env.storage() + .instance() + .set(&DataKey::Breach(resolved.id), &resolved); + status.active_breach_id = 0; + return (status, None); + } + return (status, None); + } + + if active_breach.is_some() { + return (status, None); + } + + let breach_id: u64 = env.storage().instance().get(&DataKey::BreachCount).unwrap_or(0) + 1; + let credit_amount = calculate_credit_amount(&status); + let breach = SLABreach { + id: breach_id, + merchant_id: merchant_id.clone(), + detected_at: now, + uptime_target_bps: status.uptime_target_bps, + uptime_bps: status.uptime_bps, + measurement_interval: status.measurement_interval, + observed_seconds: status.observed_seconds, + downtime_seconds: status.downtime_seconds, + partial_outage_seconds: status.partial_outage_seconds, + maintenance_seconds: status.maintenance_seconds, + credit_amount, + resolved_at: 0, + acknowledged: false, + }; + + env.storage().instance().set(&DataKey::BreachCount, &breach_id); + env.storage().instance().set(&DataKey::Breach(breach_id), &breach); + status.breach_count += 1; + status.active_breach_id = breach_id; + status.credit_balance += credit_amount; + status.last_breach_at = now; + + env.events().publish( + (Symbol::new(env, "sla_breach"), merchant_id.clone()), + (breach_id, status.uptime_bps, credit_amount), + ); + + (status, Some(breach)) +} + +#[contract] +pub struct SlaMonitoring; + +#[contractimpl] +impl SlaMonitoring { + pub fn initialize(env: Env, admin: Address) { + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + pub fn configure_sla(env: Env, merchant_id: Address, config: SLAConfig) { + let admin = get_admin(&env); + admin.require_auth(); + + let normalized = SLAConfig { + uptime_target_bps: config.uptime_target_bps.min(10_000).max(1), + measurement_interval: config.measurement_interval.max(1), + }; + env.storage() + .instance() + .set(&DataKey::Config(merchant_id.clone()), &normalized); + + let (status, _) = upsert_status(&env, &merchant_id, &normalized); + env.storage().instance().set(&DataKey::Status(merchant_id), &status); + } + + pub fn record_service_availability( + env: Env, + merchant_id: Address, + duration_secs: u64, + state: AvailabilityState, + note: String, + ) { + let config: SLAConfig = read_config(&env, &merchant_id).unwrap_or(SLAConfig { + uptime_target_bps: DEFAULT_UPTIME_TARGET_BPS, + measurement_interval: DEFAULT_MEASUREMENT_INTERVAL_SECS, + }); + let next_id: u64 = env + .storage() + .instance() + .get(&DataKey::SampleCount(merchant_id.clone())) + .unwrap_or(0) + + 1; + let sample = AvailabilitySample { + id: next_id, + merchant_id: merchant_id.clone(), + timestamp: env.ledger().timestamp(), + duration_secs: duration_secs.max(1), + state, + note, + }; + + env.storage() + .instance() + .set(&DataKey::SampleCount(merchant_id.clone()), &next_id); + env.storage() + .instance() + .set(&DataKey::Sample(merchant_id.clone(), next_id), &sample); + + let (status, _) = upsert_status(&env, &merchant_id, &config); + env.storage().instance().set(&DataKey::Status(merchant_id), &status); + } + + pub fn detect_sla_breach(env: Env, merchant_id: Address) { + let config: SLAConfig = read_config(&env, &merchant_id).unwrap_or(SLAConfig { + uptime_target_bps: DEFAULT_UPTIME_TARGET_BPS, + measurement_interval: DEFAULT_MEASUREMENT_INTERVAL_SECS, + }); + let (status, _) = upsert_status(&env, &merchant_id, &config); + env.storage().instance().set(&DataKey::Status(merchant_id), &status); + } + + pub fn get_sla_status(env: Env, merchant_id: Address) -> SLAStatus { + let config = read_config(&env, &merchant_id).unwrap_or(SLAConfig { + uptime_target_bps: DEFAULT_UPTIME_TARGET_BPS, + measurement_interval: DEFAULT_MEASUREMENT_INTERVAL_SECS, + }); + let (status, _) = upsert_status(&env, &merchant_id, &config); + env.storage().instance().set(&DataKey::Status(merchant_id), &status); + status + } + + pub fn get_sla_breaches(env: Env, merchant_id: Address) -> Vec { + merchant_breaches(&env, &merchant_id) + } + + pub fn get_sla_breach(env: Env, breach_id: u64) -> SLABreach { + read_breach(&env, breach_id).expect("Breach not found") + } + + pub fn calculate_credit(env: Env, breach_id: u64) -> i128 { + let breach = read_breach(&env, breach_id).expect("Breach not found"); + breach.credit_amount + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger}; + + struct Setup { + env: Env, + contract_id: Address, + merchant: Address, + } + + impl Setup { + fn client(&self) -> SlaMonitoringClient<'_> { + SlaMonitoringClient::new(&self.env, &self.contract_id) + } + } + + fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + env.ledger().set_timestamp(1_700_000_000); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let contract_id = env.register_contract(None, SlaMonitoring); + let client = SlaMonitoringClient::new(&env, &contract_id); + client.initialize(&admin); + Setup { + env, + contract_id, + merchant, + } + } + + #[test] + fn config_and_status_start_compliant() { + let setup = setup(); + let config = SLAConfig { + uptime_target_bps: 9_950, + measurement_interval: 86_400, + }; + + setup.client().configure_sla(&setup.merchant, &config); + let status = setup.client().get_sla_status(&setup.merchant); + + assert!(status.compliant); + assert_eq!(status.uptime_bps, 10_000); + assert_eq!(status.active_breach_id, 0); + } + + #[test] + fn breach_detection_records_credit_and_notification_event() { + let setup = setup(); + let config = SLAConfig { + uptime_target_bps: 9_950, + measurement_interval: 86_400, + }; + setup.client().configure_sla(&setup.merchant, &config); + + setup.env.ledger().set_timestamp(1_700_000_600); + setup.client().record_service_availability( + &setup.merchant, + &3_600, + &AvailabilityState::FullOutage, + &String::from_str(&setup.env, "incident"), + ); + + setup.env.ledger().set_timestamp(1_700_004_200); + let status = setup.client().get_sla_status(&setup.merchant); + assert!(!status.compliant); + assert!(status.active_breach_id > 0); + + let breach = setup.client().get_sla_breach(&status.active_breach_id); + assert!(breach.credit_amount > 0); + assert_eq!(setup.client().calculate_credit(&breach.id), breach.credit_amount); + } + + #[test] + fn maintenance_does_not_count_as_downtime() { + let setup = setup(); + let config = SLAConfig { + uptime_target_bps: 9_900, + measurement_interval: 86_400, + }; + setup.client().configure_sla(&setup.merchant, &config); + + setup.env.ledger().set_timestamp(1_700_001_000); + setup.client().record_service_availability( + &setup.merchant, + &3_600, + &AvailabilityState::Maintenance, + &String::from_str(&setup.env, "scheduled"), + ); + + setup.env.ledger().set_timestamp(1_700_004_600); + let status = setup.client().get_sla_status(&setup.merchant); + assert!(status.compliant); + assert_eq!(status.downtime_seconds, 0); + assert_eq!(status.maintenance_seconds, 3_600); + } +} diff --git a/contracts/sla/test_snapshots/test/breach_detection_records_credit_and_notification_event.1.json b/contracts/sla/test_snapshots/test/breach_detection_records_credit_and_notification_event.1.json new file mode 100644 index 0000000..d1f5f5a --- /dev/null +++ b/contracts/sla/test_snapshots/test/breach_detection_records_credit_and_notification_event.1.json @@ -0,0 +1,1192 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "configure_sla", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 21, + "sequence_number": 0, + "timestamp": 1700004200, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Breach" + }, + { + "u64": 1 + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "acknowledged" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "credit_amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 41458 + } + } + }, + { + "key": { + "symbol": "detected_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "resolved_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "BreachCount" + } + ] + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Config" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Sample" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": 1 + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "duration_secs" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "note" + }, + "val": { + "string": "incident" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "vec": [ + { + "symbol": "FullOutage" + } + ] + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": 1700000600 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "SampleCount" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Status" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 41458 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "initialize" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "initialize" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "configure_sla" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "configure_sla" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "record_service_availability" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": 3600 + }, + { + "vec": [ + { + "symbol": "FullOutage" + } + ] + }, + { + "string": "incident" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "record_service_availability" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "sla_breach" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ], + "data": { + "vec": [ + { + "u64": 1 + }, + { + "u32": 0 + }, + { + "i128": { + "hi": 0, + "lo": 41458 + } + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 41458 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "get_sla_breach" + } + ], + "data": { + "u64": 1 + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "get_sla_breach" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "acknowledged" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "credit_amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 41458 + } + } + }, + { + "key": { + "symbol": "detected_at" + }, + "val": { + "u64": 1700004200 + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "resolved_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "calculate_credit" + } + ], + "data": { + "u64": 1 + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "calculate_credit" + } + ], + "data": { + "i128": { + "hi": 0, + "lo": 41458 + } + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file diff --git a/contracts/sla/test_snapshots/test/config_and_status_start_compliant.1.json b/contracts/sla/test_snapshots/test/config_and_status_start_compliant.1.json new file mode 100644 index 0000000..cf5ea15 --- /dev/null +++ b/contracts/sla/test_snapshots/test/config_and_status_start_compliant.1.json @@ -0,0 +1,659 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "configure_sla", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 21, + "sequence_number": 0, + "timestamp": 1700000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Config" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Status" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700000000 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 10000 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "initialize" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "initialize" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "configure_sla" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "configure_sla" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700000000 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 10000 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9950 + } + } + ] + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file diff --git a/contracts/sla/test_snapshots/test/maintenance_does_not_count_as_downtime.1.json b/contracts/sla/test_snapshots/test/maintenance_does_not_count_as_downtime.1.json new file mode 100644 index 0000000..3f0975e --- /dev/null +++ b/contracts/sla/test_snapshots/test/maintenance_does_not_count_as_downtime.1.json @@ -0,0 +1,810 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "initialize", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "configure_sla", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9900 + } + } + ] + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 21, + "sequence_number": 0, + "timestamp": 1700004600, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": 5541220902715666415 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Config" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9900 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Sample" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": 1 + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "duration_secs" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "note" + }, + "val": { + "string": "scheduled" + } + }, + { + "key": { + "symbol": "state" + }, + "val": { + "vec": [ + { + "symbol": "Maintenance" + } + ] + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": 1700001000 + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "SampleCount" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "u64": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Status" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700004600 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 10000 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9900 + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "initialize" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "initialize" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "configure_sla" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "map": [ + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9900 + } + } + ] + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "configure_sla" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "record_service_availability" + } + ], + "data": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "u64": 3600 + }, + { + "vec": [ + { + "symbol": "Maintenance" + } + ] + }, + { + "string": "scheduled" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "record_service_availability" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": null, + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000003", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "get_sla_status" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "active_breach_id" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "breach_count" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "compliant" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "credit_balance" + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "symbol": "downtime_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_breach_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "last_updated_at" + }, + "val": { + "u64": 1700004600 + } + }, + { + "key": { + "symbol": "maintenance_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "measurement_interval" + }, + "val": { + "u64": 86400 + } + }, + { + "key": { + "symbol": "merchant_id" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "observed_seconds" + }, + "val": { + "u64": 3600 + } + }, + { + "key": { + "symbol": "partial_outage_seconds" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "uptime_bps" + }, + "val": { + "u32": 10000 + } + }, + { + "key": { + "symbol": "uptime_target_bps" + }, + "val": { + "u32": 9900 + } + } + ] + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file diff --git a/contracts/storage/Cargo.toml b/contracts/storage/Cargo.toml index 22586ef..ebfc0e4 100644 --- a/contracts/storage/Cargo.toml +++ b/contracts/storage/Cargo.toml @@ -6,6 +6,7 @@ authors = ["SubTrackr Team"] description = "State storage contract for SubTrackr (Soroban)" [lib] +path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [dependencies] diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 6776348..c10a7c8 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -6,6 +6,7 @@ authors = ["SubTrackr Team"] description = "SubTrackr subscription implementation contract (Soroban)" [lib] +path = "src/lib.rs" crate-type = ["cdylib", "rlib"] [dependencies] diff --git a/contracts/types/Cargo.toml b/contracts/types/Cargo.toml index e359fec..f7de6d5 100644 --- a/contracts/types/Cargo.toml +++ b/contracts/types/Cargo.toml @@ -6,6 +6,7 @@ authors = ["SubTrackr Team"] description = "Shared contract types for SubTrackr (Soroban)" [lib] +path = "src/lib.rs" crate-type = ["rlib"] [dependencies] diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b61f0e5..0e322e3 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -12,6 +12,7 @@ import CommunityScreen from '../screens/CommunityScreen'; import ProfileScreen from '../screens/ProfileScreen'; import SubscriptionDetailScreen from '../screens/SubscriptionDetailScreen'; import AnalyticsScreen from '../screens/AnalyticsScreen'; +import SlaDashboard from '../screens/SlaDashboard'; import GDPRSettingsScreen from '../screens/GDPRSettingsScreen'; import LanguageSettingsScreen from '../screens/LanguageSettingsScreen'; import SessionManagementScreen from '../screens/SessionManagementScreen'; @@ -56,6 +57,11 @@ const HomeStack = () => ( component={CommunityScreen} options={{ title: 'Community', headerShown: true }} /> + { → + navigation.navigate('SlaDashboard')} + accessibilityRole="button" + accessibilityLabel="SLA dashboard" + accessibilityHint="Opens the SLA management dashboard"> + SLA Dashboard + + → + + navigation.navigate('LanguageSettings')} diff --git a/src/screens/SlaDashboard.tsx b/src/screens/SlaDashboard.tsx new file mode 100644 index 0000000..d04cb1b --- /dev/null +++ b/src/screens/SlaDashboard.tsx @@ -0,0 +1,418 @@ +import React, { useMemo, useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TouchableOpacity, + TextInput, +} from 'react-native'; +import { Card } from '../components/common/Card'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useSlaStore } from '../store/slaStore'; +import { SlaAvailabilityState } from '../types/sla'; +import { SLA_DEFAULTS } from '../services/slaService'; + +const STATE_OPTIONS: { label: string; value: SlaAvailabilityState; description: string }[] = [ + { label: 'Healthy', value: 'healthy', description: 'Full availability' }, + { label: 'Partial', value: 'partial_outage', description: 'Degraded service' }, + { label: 'Outage', value: 'full_outage', description: 'Full downtime' }, + { label: 'Maintenance', value: 'maintenance', description: 'Planned maintenance' }, +]; + +const formatPercent = (value: number) => `${value.toFixed(2)}%`; + +const SlaDashboard: React.FC = () => { + const { configs, statuses, breaches, report, configureSla, trackServiceAvailability, refreshReport } = + useSlaStore(); + const [merchantId, setMerchantId] = useState('merchant-demo'); + const [uptimeTarget, setUptimeTarget] = useState(String(SLA_DEFAULTS.uptimeTarget)); + const [measurementInterval, setMeasurementInterval] = useState(String(SLA_DEFAULTS.measurementInterval)); + const [durationSeconds, setDurationSeconds] = useState('3600'); + const [state, setState] = useState('healthy'); + const [note, setNote] = useState(''); + + const merchantStatus = useMemo(() => statuses[merchantId] ?? null, [merchantId, statuses]); + const merchantConfig = useMemo(() => configs[merchantId] ?? null, [merchantId, configs]); + const merchantBreaches = useMemo( + () => breaches.filter((breach) => breach.merchantId === merchantId), + [breaches, merchantId] + ); + + const onConfigure = () => { + void configureSla(merchantId.trim(), { + uptimeTarget: Number(uptimeTarget), + measurementInterval: Number(measurementInterval), + }); + }; + + const onRecordAvailability = () => { + void trackServiceAvailability(merchantId.trim(), { + durationSeconds: Number(durationSeconds), + state, + note: note.trim() || undefined, + }); + }; + + return ( + + + + Merchant SLA + Availability monitoring and compliance reporting + + Configure targets, track outages, and review breach credits in one place. + + + + + + Average Uptime + {formatPercent(report.summary.averageUptime)} + + + Open Breaches + {report.summary.breachCount} + + + Credits Issued + {report.summary.totalCreditsIssued} + + + Compliant Merchants + + {report.summary.compliantMerchants}/{report.summary.totalMerchants} + + + + + + Configure SLA + + + + + + + Save SLA + + {merchantConfig && ( + + Target {merchantConfig.uptimeTarget}% over {merchantConfig.measurementInterval} seconds + + )} + + + + Service Availability + + {STATE_OPTIONS.map((option) => ( + setState(option.value)}> + + {option.label} + + {option.description} + + ))} + + + + + Record Availability + + + + + + Current Uptime + + {merchantStatus ? formatPercent(merchantStatus.uptimePercentage) : '100.00%'} + + + + Status + + {merchantStatus ? (merchantStatus.compliant ? 'Compliant' : 'Breached') : 'Idle'} + + + + + + + Merchant Status + + Refresh + + + {merchantStatus ? ( + + + Target {merchantStatus.uptimeTarget}% over {merchantStatus.measurementInterval}s + + + Observed {merchantStatus.observedSeconds.toFixed(0)}s with {merchantStatus.downtimeSeconds.toFixed(0)}s downtime + + + Partial outages {merchantStatus.partialOutageSeconds.toFixed(0)}s, maintenance {merchantStatus.maintenanceSeconds.toFixed(0)}s + + Credits: {merchantStatus.creditBalance} + + ) : ( + Configure a merchant SLA to see the live status panel. + )} + + + + Breaches + {merchantBreaches.length > 0 ? ( + merchantBreaches.map((breach) => ( + + + Breach {breach.id.slice(-6)} + {breach.creditAmount} + + + Uptime {formatPercent(breach.uptimePercentage)} vs target {breach.uptimeTarget}% + + + Downtime {breach.downtimeSeconds.toFixed(0)}s, detected {new Date(breach.detectedAt).toLocaleString()} + + + {breach.resolvedAt ? `Resolved ${new Date(breach.resolvedAt).toLocaleString()}` : 'Open breach'} + + + )) + ) : ( + No breaches recorded for this merchant yet. + )} + + + + Reporting Snapshot + + Partial outages: {report.summary.partialOutageEvents} | Scheduled maintenance:{' '} + {report.summary.maintenanceEvents} + + + Known merchants: {Object.keys(report.configs).length} | Open breaches:{' '} + {report.summary.breachCount} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + scrollContent: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.lg, + }, + hero: { + marginBottom: spacing.sm, + }, + kicker: { + color: colors.accent, + textTransform: 'uppercase', + letterSpacing: 1.2, + fontSize: typography.small.fontSize, + marginBottom: spacing.xs, + }, + title: { + ...typography.h1, + color: colors.text, + marginBottom: spacing.sm, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + }, + summaryGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.md, + }, + summaryCard: { + flexGrow: 1, + minWidth: '47%', + }, + summaryLabel: { + ...typography.small, + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + summaryValue: { + ...typography.h2, + color: colors.text, + }, + section: { + gap: spacing.md, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + }, + sectionHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.text, + backgroundColor: 'rgba(255,255,255,0.02)', + }, + inlineInputs: { + flexDirection: 'row', + gap: spacing.sm, + }, + inlineInput: { + flex: 1, + }, + primaryButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + primaryButtonText: { + color: colors.text, + fontWeight: '700', + }, + secondaryButton: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + secondaryButtonText: { + color: colors.text, + fontWeight: '600', + }, + helperText: { + color: colors.textSecondary, + fontSize: typography.small.fontSize, + }, + pillGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + statePill: { + flexBasis: '48%', + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.md, + backgroundColor: 'rgba(255,255,255,0.02)', + gap: spacing.xs, + }, + statePillActive: { + borderColor: colors.primary, + backgroundColor: 'rgba(99, 102, 241, 0.15)', + }, + statePillLabel: { + color: colors.text, + fontWeight: '700', + }, + statePillLabelActive: { + color: colors.accent, + }, + statePillDescription: { + color: colors.textSecondary, + fontSize: typography.small.fontSize, + }, + statusPanel: { + gap: spacing.xs, + }, + statusLine: { + color: colors.text, + fontSize: typography.body.fontSize, + }, + breachRow: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + padding: spacing.md, + gap: spacing.xs, + }, + breachHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + breachTitle: { + color: colors.text, + fontWeight: '700', + }, + breachCredit: { + color: colors.warning, + fontWeight: '700', + }, + breachMeta: { + color: colors.textSecondary, + fontSize: typography.small.fontSize, + }, + linkText: { + color: colors.accent, + fontWeight: '600', + }, + emptyText: { + color: colors.textSecondary, + }, +}); + +export default SlaDashboard; diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 7330b14..8aa17c2 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -9,6 +9,7 @@ export const NOTIFICATION_DATA_TYPE = { CHARGE_SUCCESS: 'charge_success', CHARGE_FAILED: 'charge_failed', TRANSACTION_QUEUE: 'transaction_queue', + SLA_BREACH: 'sla_breach', } as const; const ANDROID_CHANNEL_ID = 'billing'; @@ -193,6 +194,33 @@ export async function presentTransactionQueueNotification( }); } +export async function presentSlaBreachNotification(input: { + merchantName: string; + uptimeTarget: number; + uptimePercentage: number; + creditAmount: number; +}): Promise { + if (!isNotificationsSupported()) return; + const status = await getPermissionStatus(); + if (status !== Notifications.PermissionStatus.GRANTED) return; + + await Notifications.scheduleNotificationAsync({ + content: { + title: `SLA breach: ${input.merchantName}`, + body: `Uptime dropped to ${input.uptimePercentage.toFixed(2)}% against a ${input.uptimeTarget}% target. Credit due: ${input.creditAmount}.`, + data: { + type: NOTIFICATION_DATA_TYPE.SLA_BREACH, + merchantName: input.merchantName, + uptimeTarget: input.uptimeTarget, + uptimePercentage: input.uptimePercentage, + creditAmount: input.creditAmount, + }, + sound: 'default', + }, + trigger: null, + }); +} + export async function presentLocalNotification(input: { title: string; body: string; diff --git a/src/services/slaService.ts b/src/services/slaService.ts new file mode 100644 index 0000000..c2188be --- /dev/null +++ b/src/services/slaService.ts @@ -0,0 +1,226 @@ +import type { + SlaAvailabilityEvent, + SlaAvailabilityState, + SlaBreach, + SlaConfig, + SlaDashboardReport, + SlaStatus, +} from '../types/sla'; + +export const SLA_DEFAULTS = { + uptimeTarget: 99, + measurementInterval: 7 * 24 * 60 * 60, +} as const; + +const UPSIDE_WEIGHT: Record = { + healthy: 0, + partial_outage: 0.5, + full_outage: 1, + maintenance: 0, +}; + +function generateId(prefix: string): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).slice(2, 8); + return `${prefix}-${timestamp}-${random}`; +} + +function clampPercentage(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, Number(value.toFixed(2)))); +} + +export function normalizeSlaConfig(merchantId: string, input: Partial): SlaConfig { + return { + merchantId, + uptimeTarget: Number.isFinite(input.uptimeTarget) ? Number(input.uptimeTarget) : SLA_DEFAULTS.uptimeTarget, + measurementInterval: Number.isFinite(input.measurementInterval) + ? Math.max(1, Math.floor(Number(input.measurementInterval))) + : SLA_DEFAULTS.measurementInterval, + subscriberContacts: Array.isArray(input.subscriberContacts) ? [...input.subscriberContacts] : [], + }; +} + +export function calculateAvailabilityImpact(event: SlaAvailabilityEvent): { + downtimeSeconds: number; + partialOutageSeconds: number; + maintenanceSeconds: number; +} { + const downtimeSeconds = event.durationSeconds * UPSIDE_WEIGHT[event.state]; + return { + downtimeSeconds, + partialOutageSeconds: event.state === 'partial_outage' ? event.durationSeconds : 0, + maintenanceSeconds: event.state === 'maintenance' ? event.durationSeconds : 0, + }; +} + +export function calculateUptimePercentage(observedSeconds: number, downtimeSeconds: number): number { + if (observedSeconds <= 0) return 100; + return clampPercentage(100 - (downtimeSeconds / observedSeconds) * 100); +} + +export function calculateCreditAmount(breach: Pick): number { + if (breach.uptimePercentage >= breach.uptimeTarget) return 0; + + const deficit = breach.uptimeTarget - breach.uptimePercentage; + const normalizedDeficit = deficit / Math.max(breach.uptimeTarget, 1); + const rawCredit = normalizedDeficit * breach.measurementInterval * 100; + return Math.max(1, Math.round(rawCredit)); +} + +export function calculateMerchantStatus( + config: SlaConfig, + events: SlaAvailabilityEvent[], + breaches: SlaBreach[], + now: number = Date.now() +): SlaStatus { + const windowStart = now - config.measurementInterval * 1000; + + let observedSeconds = 0; + let downtimeSeconds = 0; + let partialOutageSeconds = 0; + let maintenanceSeconds = 0; + + for (const event of events) { + const eventStart = event.timestamp; + const eventEnd = event.timestamp + event.durationSeconds * 1000; + const overlapStart = Math.max(eventStart, windowStart); + const overlapEnd = Math.min(eventEnd, now); + if (overlapEnd <= overlapStart) continue; + + const overlapSeconds = (overlapEnd - overlapStart) / 1000; + const impact = calculateAvailabilityImpact({ ...event, durationSeconds: overlapSeconds }); + observedSeconds += overlapSeconds; + downtimeSeconds += impact.downtimeSeconds; + partialOutageSeconds += impact.partialOutageSeconds; + maintenanceSeconds += impact.maintenanceSeconds; + } + + const uptimePercentage = calculateUptimePercentage(observedSeconds, downtimeSeconds); + const merchantBreaches = breaches.filter((breach) => breach.merchantId === config.merchantId); + const openBreach = [...merchantBreaches].reverse().find((breach) => !breach.resolvedAt) ?? null; + + return { + merchantId: config.merchantId, + uptimeTarget: config.uptimeTarget, + measurementInterval: config.measurementInterval, + observedSeconds: Number(observedSeconds.toFixed(2)), + uptimePercentage, + downtimeSeconds: Number(downtimeSeconds.toFixed(2)), + partialOutageSeconds: Number(partialOutageSeconds.toFixed(2)), + maintenanceSeconds: Number(maintenanceSeconds.toFixed(2)), + breachCount: merchantBreaches.length, + activeBreachId: openBreach?.id ?? null, + creditBalance: merchantBreaches.reduce((sum, breach) => sum + breach.creditAmount, 0), + compliant: uptimePercentage >= config.uptimeTarget, + lastUpdatedAt: now, + lastBreachAt: merchantBreaches.length + ? Math.max(...merchantBreaches.map((breach) => breach.detectedAt)) + : null, + }; +} + +export interface EvaluateMerchantSnapshotInput { + config: SlaConfig; + events: SlaAvailabilityEvent[]; + breaches: SlaBreach[]; + now?: number; +} + +export interface EvaluateMerchantSnapshotResult { + status: SlaStatus; + breaches: SlaBreach[]; + createdBreach: SlaBreach | null; + resolvedBreachId: string | null; +} + +export function evaluateMerchantSnapshot( + input: EvaluateMerchantSnapshotInput +): EvaluateMerchantSnapshotResult { + const now = input.now ?? Date.now(); + const status = calculateMerchantStatus(input.config, input.events, input.breaches, now); + const merchantBreaches = input.breaches.filter((breach) => breach.merchantId === input.config.merchantId); + const activeBreach = [...merchantBreaches].reverse().find((breach) => !breach.resolvedAt) ?? null; + + if (!status.compliant && !activeBreach) { + const breach: SlaBreach = { + id: generateId('breach'), + merchantId: input.config.merchantId, + detectedAt: now, + uptimeTarget: status.uptimeTarget, + uptimePercentage: status.uptimePercentage, + measurementInterval: status.measurementInterval, + observedSeconds: status.observedSeconds, + downtimeSeconds: status.downtimeSeconds, + partialOutageSeconds: status.partialOutageSeconds, + maintenanceSeconds: status.maintenanceSeconds, + creditAmount: calculateCreditAmount({ + uptimeTarget: status.uptimeTarget, + uptimePercentage: status.uptimePercentage, + measurementInterval: status.measurementInterval, + }), + resolvedAt: null, + acknowledged: false, + }; + + return { + status: { ...status, activeBreachId: breach.id }, + breaches: [...input.breaches, breach], + createdBreach: breach, + resolvedBreachId: null, + }; + } + + if (status.compliant && activeBreach) { + const resolvedBreaches = input.breaches.map((breach) => + breach.id === activeBreach.id ? { ...breach, resolvedAt: now } : breach + ); + + return { + status: { ...status, activeBreachId: null }, + breaches: resolvedBreaches, + createdBreach: null, + resolvedBreachId: activeBreach.id, + }; + } + + return { + status: { ...status, activeBreachId: activeBreach?.id ?? null }, + breaches: input.breaches, + createdBreach: null, + resolvedBreachId: null, + }; +} + +export function buildSlaDashboardReport(input: { + configs: Record; + statuses: Record; + breaches: SlaBreach[]; + events: SlaAvailabilityEvent[]; +}): SlaDashboardReport { + const merchantIds = Object.keys(input.configs); + const summary = { + totalMerchants: merchantIds.length, + compliantMerchants: merchantIds.filter((merchantId) => input.statuses[merchantId]?.compliant).length, + breachCount: input.breaches.filter((breach) => !breach.resolvedAt).length, + averageUptime: merchantIds.length + ? Number( + ( + merchantIds.reduce((sum, merchantId) => sum + (input.statuses[merchantId]?.uptimePercentage ?? 100), 0) / + merchantIds.length + ).toFixed(2) + ) + : 100, + totalCreditsIssued: input.breaches.reduce((sum, breach) => sum + breach.creditAmount, 0), + partialOutageEvents: input.events.filter((event) => event.state === 'partial_outage').length, + maintenanceEvents: input.events.filter((event) => event.state === 'maintenance').length, + }; + + return { + summary, + configs: { ...input.configs }, + statuses: { ...input.statuses }, + breaches: [...input.breaches], + events: [...input.events], + }; +} diff --git a/src/store/__tests__/slaStore.test.ts b/src/store/__tests__/slaStore.test.ts new file mode 100644 index 0000000..c66e07d --- /dev/null +++ b/src/store/__tests__/slaStore.test.ts @@ -0,0 +1,129 @@ +import { act } from 'react'; +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSlaStore } from '../slaStore'; + +const mockMemoryStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn((key: string, value: string) => { + mockMemoryStore.set(key, value); + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => Promise.resolve(mockMemoryStore.get(key) ?? null)), + removeItem: jest.fn((key: string) => { + mockMemoryStore.delete(key); + return Promise.resolve(); + }), + clear: jest.fn(() => { + mockMemoryStore.clear(); + return Promise.resolve(); + }), +})); + +jest.mock('../../services/notificationService', () => ({ + syncRenewalReminders: jest.fn(() => Promise.resolve()), + presentChargeSuccessNotification: jest.fn(() => Promise.resolve()), + presentChargeFailedNotification: jest.fn(() => Promise.resolve()), + presentLocalNotification: jest.fn(() => Promise.resolve()), + presentSlaBreachNotification: jest.fn(() => Promise.resolve()), +})); + +describe('slaStore', () => { + beforeEach(() => { + mockMemoryStore.clear(); + (AsyncStorage.setItem as jest.Mock).mockClear(); + (AsyncStorage.getItem as jest.Mock).mockClear(); + (AsyncStorage.removeItem as jest.Mock).mockClear(); + + useSlaStore.setState({ + configs: {}, + statuses: {}, + availabilityEvents: [], + breaches: [], + report: { + summary: { + totalMerchants: 0, + compliantMerchants: 0, + breachCount: 0, + averageUptime: 100, + totalCreditsIssued: 0, + partialOutageEvents: 0, + maintenanceEvents: 0, + }, + configs: {}, + statuses: {}, + breaches: [], + events: [], + }, + isLoading: false, + error: null, + }); + }); + + it('configures SLA targets and computes a healthy status', async () => { + await act(async () => { + await useSlaStore.getState().configureSla('merchant-a', { + uptimeTarget: 99.5, + measurementInterval: 86_400, + }); + }); + + const status = useSlaStore.getState().getSlaStatus('merchant-a'); + + expect(status?.uptimeTarget).toBe(99.5); + expect(status?.measurementInterval).toBe(86_400); + expect(status?.compliant).toBe(true); + }); + + it('creates a breach and sends a notification when uptime drops below target', async () => { + const notify = jest.requireMock('../../services/notificationService') + .presentSlaBreachNotification as jest.Mock; + + await act(async () => { + await useSlaStore.getState().configureSla('merchant-b', { + uptimeTarget: 99.9, + measurementInterval: 86_400, + }); + }); + + await act(async () => { + await useSlaStore.getState().trackServiceAvailability('merchant-b', { + durationSeconds: 7_200, + state: 'full_outage', + note: 'ISP incident', + }); + }); + + const status = useSlaStore.getState().getSlaStatus('merchant-b'); + const breaches = useSlaStore.getState().breaches.filter((breach) => breach.merchantId === 'merchant-b'); + + expect(status?.compliant).toBe(false); + expect(breaches).toHaveLength(1); + expect(breaches[0].creditAmount).toBeGreaterThan(0); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('treats scheduled maintenance as non-breaching availability', async () => { + await act(async () => { + await useSlaStore.getState().configureSla('merchant-c', { + uptimeTarget: 99, + measurementInterval: 86_400, + }); + }); + + await act(async () => { + await useSlaStore.getState().trackServiceAvailability('merchant-c', { + durationSeconds: 3_600, + state: 'maintenance', + note: 'scheduled patching', + }); + }); + + const status = useSlaStore.getState().getSlaStatus('merchant-c'); + + expect(status?.compliant).toBe(true); + expect(status?.maintenanceSeconds).toBe(3_600); + expect(useSlaStore.getState().breaches).toHaveLength(0); + }); +}); diff --git a/src/store/slaStore.ts b/src/store/slaStore.ts new file mode 100644 index 0000000..0d48770 --- /dev/null +++ b/src/store/slaStore.ts @@ -0,0 +1,300 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { + SlaAvailabilityEvent, + SlaAvailabilityState, + SlaBreach, + SlaConfig, + SlaDashboardReport, + SlaStatus, +} from '../types/sla'; +import { + buildSlaDashboardReport, + evaluateMerchantSnapshot, + normalizeSlaConfig, +} from '../services/slaService'; +import { presentSlaBreachNotification } from '../services/notificationService'; +import { errorHandler, AppError } from '../services/errorHandler'; + +const STORAGE_KEY = 'subtrackr-sla'; + +function generateId(prefix: string): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).slice(2, 8); + return `${prefix}-${timestamp}-${random}`; +} + +interface TrackAvailabilityInput { + durationSeconds: number; + state: SlaAvailabilityState; + note?: string; + timestamp?: number; +} + +interface SlaState { + configs: Record; + statuses: Record; + availabilityEvents: SlaAvailabilityEvent[]; + breaches: SlaBreach[]; + report: SlaDashboardReport; + isLoading: boolean; + error: AppError | null; + configureSla: (merchantId: string, config: Partial) => Promise; + trackServiceAvailability: (merchantId: string, input: TrackAvailabilityInput) => Promise; + detectSlaBreach: (merchantId: string) => Promise; + acknowledgeBreach: (breachId: string) => Promise; + calculateCredit: (breachId: string) => number; + getSlaStatus: (merchantId: string) => SlaStatus | null; + refreshReport: () => void; +} + +function buildEmptyReport(): SlaDashboardReport { + return { + summary: { + totalMerchants: 0, + compliantMerchants: 0, + breachCount: 0, + averageUptime: 100, + totalCreditsIssued: 0, + partialOutageEvents: 0, + maintenanceEvents: 0, + }, + configs: {}, + statuses: {}, + breaches: [], + events: [], + }; +} + +function updateMerchantState(state: SlaState, merchantId: string, now = Date.now()) { + const config = state.configs[merchantId]; + if (!config) { + return { + statuses: state.statuses, + breaches: state.breaches, + createdBreach: null as SlaBreach | null, + resolvedBreachId: null as string | null, + }; + } + + const merchantEvents = state.availabilityEvents.filter((event) => event.merchantId === merchantId); + const merchantBreaches = state.breaches.filter((breach) => breach.merchantId === merchantId); + const evaluation = evaluateMerchantSnapshot({ + config, + events: merchantEvents, + breaches: merchantBreaches, + now, + }); + + const nextBreaches = state.breaches + .filter((breach) => breach.merchantId !== merchantId) + .concat(evaluation.breaches); + + return { + statuses: { + ...state.statuses, + [merchantId]: evaluation.status, + }, + breaches: nextBreaches, + createdBreach: evaluation.createdBreach, + resolvedBreachId: evaluation.resolvedBreachId, + }; +} + +function rebuildReport(state: Pick): SlaDashboardReport { + return buildSlaDashboardReport({ + configs: state.configs, + statuses: state.statuses, + breaches: state.breaches, + events: state.availabilityEvents, + }); +} + +export const useSlaStore = create()( + persist( + (set, get) => ({ + configs: {}, + statuses: {}, + availabilityEvents: [], + breaches: [], + report: buildEmptyReport(), + isLoading: false, + error: null, + + configureSla: async (merchantId, config) => { + set({ isLoading: true, error: null }); + try { + const normalized = normalizeSlaConfig(merchantId, config); + set((state) => { + const nextState: SlaState = { + ...state, + configs: { + ...state.configs, + [merchantId]: normalized, + }, + }; + const evaluated = updateMerchantState(nextState, merchantId); + return { + configs: nextState.configs, + statuses: evaluated.statuses, + breaches: evaluated.breaches, + report: rebuildReport({ + configs: nextState.configs, + statuses: evaluated.statuses, + breaches: evaluated.breaches, + availabilityEvents: state.availabilityEvents, + }), + isLoading: false, + }; + }); + } catch (error) { + set({ + error: errorHandler.handleError(error as Error, { + action: 'configureSla', + metadata: { merchantId, config }, + }), + isLoading: false, + }); + } + }, + + trackServiceAvailability: async (merchantId, input) => { + set({ isLoading: true, error: null }); + try { + const event: SlaAvailabilityEvent = { + id: generateId('sla-event'), + merchantId, + timestamp: input.timestamp ?? Date.now(), + durationSeconds: Math.max(1, Math.floor(input.durationSeconds)), + state: input.state, + note: input.note, + }; + + let createdBreach: SlaBreach | null = null; + + set((state) => { + const availabilityEvents = [...state.availabilityEvents, event]; + const nextState: SlaState = { + ...state, + availabilityEvents, + }; + const evaluated = updateMerchantState(nextState, merchantId, event.timestamp); + createdBreach = evaluated.createdBreach; + + return { + availabilityEvents, + statuses: evaluated.statuses, + breaches: evaluated.breaches, + report: rebuildReport({ + configs: state.configs, + statuses: evaluated.statuses, + breaches: evaluated.breaches, + availabilityEvents, + }), + isLoading: false, + }; + }); + + if (createdBreach) { + const config = get().configs[merchantId]; + void presentSlaBreachNotification({ + merchantName: config?.merchantId ?? merchantId, + uptimeTarget: createdBreach.uptimeTarget, + uptimePercentage: createdBreach.uptimePercentage, + creditAmount: createdBreach.creditAmount, + }); + } + } catch (error) { + set({ + error: errorHandler.handleError(error as Error, { + action: 'trackServiceAvailability', + metadata: { merchantId, input }, + }), + isLoading: false, + }); + } + }, + + detectSlaBreach: async (merchantId) => { + const state = get(); + const config = state.configs[merchantId]; + if (!config) return null; + + const evaluated = updateMerchantState(state, merchantId); + set({ + statuses: evaluated.statuses, + breaches: evaluated.breaches, + report: rebuildReport({ + configs: state.configs, + statuses: evaluated.statuses, + breaches: evaluated.breaches, + availabilityEvents: state.availabilityEvents, + }), + }); + + const nextStatus = evaluated.statuses[merchantId] ?? null; + if (evaluated.createdBreach) { + void presentSlaBreachNotification({ + merchantName: config.merchantId, + uptimeTarget: evaluated.createdBreach.uptimeTarget, + uptimePercentage: evaluated.createdBreach.uptimePercentage, + creditAmount: evaluated.createdBreach.creditAmount, + }); + } + return nextStatus; + }, + + acknowledgeBreach: async (breachId) => { + set({ isLoading: true, error: null }); + try { + set((state) => { + const breaches = state.breaches.map((breach) => + breach.id === breachId ? { ...breach, acknowledged: true } : breach + ); + return { + breaches, + report: rebuildReport({ + configs: state.configs, + statuses: state.statuses, + breaches, + availabilityEvents: state.availabilityEvents, + }), + isLoading: false, + }; + }); + } catch (error) { + set({ + error: errorHandler.handleError(error as Error, { + action: 'acknowledgeBreach', + metadata: { breachId }, + }), + isLoading: false, + }); + } + }, + + calculateCredit: (breachId) => get().breaches.find((breach) => breach.id === breachId)?.creditAmount ?? 0, + + getSlaStatus: (merchantId) => get().statuses[merchantId] ?? null, + + refreshReport: () => { + const state = get(); + set({ + report: rebuildReport(state), + }); + }, + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => AsyncStorage), + version: 1, + partialize: (state) => ({ + configs: state.configs, + statuses: state.statuses, + availabilityEvents: state.availabilityEvents, + breaches: state.breaches, + }), + } + ) +); diff --git a/src/types/sla.ts b/src/types/sla.ts new file mode 100644 index 0000000..b0dc5b7 --- /dev/null +++ b/src/types/sla.ts @@ -0,0 +1,68 @@ +export type SlaAvailabilityState = 'healthy' | 'partial_outage' | 'full_outage' | 'maintenance'; + +export interface SlaConfig { + merchantId: string; + uptimeTarget: number; + measurementInterval: number; + subscriberContacts?: string[]; +} + +export interface SlaAvailabilityEvent { + id: string; + merchantId: string; + timestamp: number; + durationSeconds: number; + state: SlaAvailabilityState; + note?: string; +} + +export interface SlaBreach { + id: string; + merchantId: string; + detectedAt: number; + uptimeTarget: number; + uptimePercentage: number; + measurementInterval: number; + observedSeconds: number; + downtimeSeconds: number; + partialOutageSeconds: number; + maintenanceSeconds: number; + creditAmount: number; + resolvedAt?: number | null; + acknowledged: boolean; +} + +export interface SlaStatus { + merchantId: string; + uptimeTarget: number; + measurementInterval: number; + observedSeconds: number; + uptimePercentage: number; + downtimeSeconds: number; + partialOutageSeconds: number; + maintenanceSeconds: number; + breachCount: number; + activeBreachId: string | null; + creditBalance: number; + compliant: boolean; + lastUpdatedAt: number; + lastBreachAt: number | null; +} + +export interface SlaDashboardSummary { + totalMerchants: number; + compliantMerchants: number; + breachCount: number; + averageUptime: number; + totalCreditsIssued: number; + partialOutageEvents: number; + maintenanceEvents: number; +} + +export interface SlaDashboardReport { + summary: SlaDashboardSummary; + configs: Record; + statuses: Record; + breaches: SlaBreach[]; + events: SlaAvailabilityEvent[]; +}