Skip to content

Commit

Permalink
Add uptime tracking
Browse files Browse the repository at this point in the history
- Added self-reported uptime tracking to devices. Uptime must be
  provided in every PollRequest, otherwise they are nulled.
- Added uptime and last_poll fields to output of read operations on the
  database (get/list).
  • Loading branch information
ngc7293 committed Sep 21, 2024
1 parent 2759d18 commit 8f2e729
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 11 deletions.
11 changes: 9 additions & 2 deletions api/ganymede/v2/device.proto
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
syntax = "proto3";

import "google/protobuf/empty.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";

package ganymede.v2;

Expand Down Expand Up @@ -53,6 +54,9 @@ message DeleteDeviceRequest {

message PollRequest {
string device_mac = 1;

// Time elapsed since last restart
google.protobuf.Duration uptime = 2;
}

message PollResponse {
Expand Down Expand Up @@ -102,7 +106,6 @@ message DeleteConfigResponse {

// Primitive message types
message Device {
// Output only.
string uid = 1;

string mac = 2;
Expand All @@ -116,6 +119,10 @@ message Device {
string timezone = 12;

string config_uid = 20;

// Output only. Time elapsed since last restart
google.protobuf.Timestamp last_poll = 100;
google.protobuf.Duration uptime = 101;
}

message Time {
Expand Down
1 change: 1 addition & 0 deletions migrations/20240921143451_Add_uptime_tracking.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE device ADD COLUMN uptime INTERVAL;
46 changes: 43 additions & 3 deletions src/database/models/device/model.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use sqlx::FromRow;
use chrono::{DateTime, TimeDelta, Utc};
use sqlx::postgres::PgRow;
use sqlx::Row;
use uuid::Uuid;

use crate::types::MacAddress;

#[derive(Debug, Clone, PartialEq, FromRow)]
#[derive(Debug, Clone, PartialEq)]
pub struct DeviceModel {
// Unique identifier
pub device_id: Uuid,
Expand All @@ -12,7 +14,6 @@ pub struct DeviceModel {
pub config_id: Uuid,

// Device's MAC address. Used by devices for self-identification during poll
#[sqlx(try_from = "String")]
pub mac: MacAddress,

// Short display name
Expand All @@ -23,4 +24,43 @@ pub struct DeviceModel {

// Timezone in tzdata <Region/City> format
pub timezone: String,

// Output only. Timestamp of last PollRequest received by the server
// This can only be set by calling update_device_last_poll; it is ignored in inserts and updates.
pub last_poll: Option<DateTime<Utc>>,

// Output only. Time since last restart.
// This can only be set by calling update_device_uptime; it is ignored in inserts and updates.
pub uptime: Option<TimeDelta>,
}

impl<'r> sqlx::FromRow<'r, PgRow> for DeviceModel {
fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
let device_id = row.try_get("device_id")?;
let config_id = row.try_get("config_id")?;
let display_name = row.try_get("display_name")?;
let description = row.try_get("description")?;
let timezone = row.try_get("timezone")?;
let last_poll = row.try_get("last_poll")?;

let mac: String = row.try_get("mac")?;
let mac: MacAddress = mac.try_into().map_err(|err| sqlx::Error::Decode(Box::new(err)))?;

let uptime: Option<sqlx::postgres::types::PgInterval> = row.try_get("uptime")?;
let uptime: Option<TimeDelta> = match uptime {
Some(interval) => Some(chrono::TimeDelta::nanoseconds(interval.microseconds * 1000)),
None => None,
};

Ok(DeviceModel {
device_id,
config_id,
mac,
display_name,
description,
timezone,
last_poll,
uptime,
})
}
}
48 changes: 44 additions & 4 deletions src/database/models/device/operations.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use uuid::Uuid;

use crate::database::DomainDatabaseTransaction;
Expand Down Expand Up @@ -41,7 +41,7 @@ impl DomainDatabaseTransaction {
pub async fn fetch_one_device(&mut self, device_id: &Uuid) -> Result<DeviceModel> {
let result = sqlx::query_as::<_, DeviceModel>(
"SELECT
device_id, domain_id, display_name, mac, config_id, description, timezone
device_id, domain_id, display_name, mac, config_id, description, timezone, last_poll, uptime
FROM device
WHERE
domain_id = $1
Expand All @@ -64,7 +64,7 @@ impl DomainDatabaseTransaction {
pub async fn fetch_many_device(&mut self, filter: DeviceFilter) -> Result<Vec<DeviceModel>> {
let mut query = sqlx::QueryBuilder::new(
"SELECT
device_id, domain_id, display_name, mac, config_id, description, timezone
device_id, domain_id, display_name, mac, config_id, description, timezone, last_poll, uptime
FROM device
WHERE domain_id =",
);
Expand Down Expand Up @@ -180,7 +180,7 @@ impl DomainDatabaseTransaction {
}
}

pub async fn update_last_poll(&mut self, device_id: &uuid::Uuid, last_poll: DateTime<Utc>) -> Result<()> {
pub async fn update_device_last_poll(&mut self, device_id: &uuid::Uuid, last_poll: DateTime<Utc>) -> Result<()> {
let result = sqlx::query(
"
UPDATE device
Expand All @@ -207,6 +207,34 @@ impl DomainDatabaseTransaction {
Err(err) => Err(err.into()),
}
}

pub async fn update_device_uptime(&mut self, device_id: &uuid::Uuid, uptime: Option<TimeDelta>) -> Result<()> {
let result = sqlx::query(
"
UPDATE device
SET
uptime = $3
WHERE
domain_id = $1
AND device_id = $2",
)
.bind(self.domain_id())
.bind(device_id)
.bind(uptime)
.execute(self.executor())
.await;

match result {
Ok(row) => match row.rows_affected() {
1 => Ok(()),
0 => Err(Error::NoSuchDevice),
n => Err(Error::DatabaseError(format!(
"Update statement affected {n} but we expected 1"
))),
},
Err(err) => Err(err.into()),
}
}
}

#[cfg(test)]
Expand Down Expand Up @@ -235,6 +263,8 @@ mod tests {
config_id: Uuid::nil(),
description: "This is a description".to_string(),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

let result = transaction.insert_device(device).await.unwrap();
Expand All @@ -254,6 +284,8 @@ mod tests {
config_id: Uuid::nil(),
description: "This is a description".to_string(),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

let result = transaction.insert_device(device).await.unwrap_err();
Expand All @@ -273,6 +305,8 @@ mod tests {
config_id: Uuid::nil(),
description: "This is a description".to_string(),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

let result = transaction.insert_device(device).await.unwrap_err();
Expand All @@ -291,6 +325,8 @@ mod tests {
mac: MacAddress::try_from("00:00:00:00:00:00".to_string()).unwrap(),
config_id: Uuid::nil(),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

let returned = transaction.update_device(updated.clone()).await.unwrap();
Expand All @@ -311,6 +347,8 @@ mod tests {
mac: MacAddress::try_from("00:00:00:00:00:00".to_string()).unwrap(),
config_id: uuid!("00000000-0000-0000-0000-000000000001"),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

let result = transaction.update_device(updated).await.unwrap_err();
Expand All @@ -329,6 +367,8 @@ mod tests {
mac: MacAddress::try_from("00:00:00:00:00:01".to_string())?,
config_id: Uuid::nil(),
timezone: "America/Montreal".to_string(),
last_poll: None,
uptime: None,
};

device.device_id = transaction.insert_device(device.clone()).await.unwrap();
Expand Down
28 changes: 28 additions & 0 deletions src/database/models/device/tonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ impl TryFrom<ganymede::v2::Device> for DeviceModel {
config_id: Uuid::try_parse(&value.config_uid)?,
description: value.description,
timezone: value.timezone.parse::<chrono_tz::Tz>()?.to_string(),
uptime: None,
last_poll: None,
};

Ok(result)
Expand All @@ -31,13 +33,25 @@ impl TryFrom<DeviceModel> for ganymede::v2::Device {
type Error = Error;

fn try_from(value: DeviceModel) -> Result<ganymede::v2::Device> {
let last_poll = match value.last_poll {
Some(timestamp) => Some(prost_types::Timestamp { seconds: timestamp.timestamp(), nanos: 0 }), // FIXME: Handle full precision
None => None
};

let uptime = match value.uptime {
Some(duration) => Some(prost_types::Duration { seconds: duration.num_seconds(), nanos: duration.subsec_nanos() }),
None => None
};

let result = ganymede::v2::Device {
uid: value.device_id.to_string(),
mac: value.mac.into(),
display_name: value.display_name,
description: value.description,
timezone: value.timezone,
config_uid: value.config_id.to_string(),
last_poll,
uptime,
};

Ok(result)
Expand All @@ -64,6 +78,8 @@ mod tests {
description: "Watch how I pour".to_string(),
timezone: "America/Caracas".to_string(),
config_uid: config_uid.to_string(),
last_poll: None,
uptime: None,
};

let model = DeviceModel::try_from(device).unwrap();
Expand All @@ -84,6 +100,8 @@ mod tests {
description: "".to_string(),
timezone: "Rohan/Edoras".to_string(),
config_uid: Uuid::nil().to_string(),
last_poll: None,
uptime: None,
};

let error = DeviceModel::try_from(device).unwrap_err();
Expand All @@ -99,6 +117,8 @@ mod tests {
description: "".to_string(),
timezone: "America/Montreal".to_string(),
config_uid: Uuid::nil().to_string(),
last_poll: None,
uptime: None,
};

let error = DeviceModel::try_from(device).unwrap_err();
Expand All @@ -114,6 +134,8 @@ mod tests {
description: "".to_string(),
timezone: "America/Montreal".to_string(),
config_uid: Uuid::nil().to_string(),
last_poll: None,
uptime: None,
};

let device = DeviceModel::try_from(device).unwrap();
Expand All @@ -129,6 +151,8 @@ mod tests {
description: "".to_string(),
timezone: "America/Montreal".to_string(),
config_uid: "not-a-uid".to_string(),
last_poll: None,
uptime: None,
};

let error = DeviceModel::try_from(device).unwrap_err();
Expand All @@ -144,6 +168,8 @@ mod tests {
description: "".to_string(),
timezone: "America/Montreal".to_string(),
config_uid: Uuid::nil().to_string(),
last_poll: None,
uptime: None,
};

let error = DeviceModel::try_from(device).unwrap_err();
Expand All @@ -159,6 +185,8 @@ mod tests {
config_id: uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"),
description: "Short and stout".to_string(),
timezone: "America/Caracas".to_string(),
last_poll: None,
uptime: None,
};

let result = ganymede::v2::Device::try_from(device).unwrap();
Expand Down
11 changes: 9 additions & 2 deletions src/services/device.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::{Offset, TimeZone};
use chrono::{Offset, TimeDelta, TimeZone};
use uuid::Uuid;

use tonic::{Request, Response, Status};
Expand Down Expand Up @@ -156,9 +156,16 @@ impl ganymede::v2::device_service_server::DeviceService for DeviceService {
None => Err(Error::NoSuchDevice)?,
};

let uptime = match payload.uptime {
Some(duration) => Some(TimeDelta::seconds(duration.seconds) + TimeDelta::nanoseconds(duration.nanos.into())),
None => None,
};
transaction.update_device_uptime(&device_id, uptime).await?;

let device = transaction.fetch_one_device(&device_id).await?;
let config = transaction.fetch_one_config(&device.config_id).await?;


let tz: chrono_tz::Tz = match device.timezone.parse() {
Ok(tz) => tz,
Err(err) => {
Expand Down Expand Up @@ -191,7 +198,7 @@ impl ganymede::v2::device_service_server::DeviceService for DeviceService {
),
};

transaction.update_last_poll(&device.device_id, chrono::offset::Utc::now()).await?;
transaction.update_device_last_poll(&device.device_id, chrono::offset::Utc::now()).await?;

transaction.commit().await?;
Ok(Response::new(response))
Expand Down

0 comments on commit 8f2e729

Please sign in to comment.