feat: graphs

closes #7
This commit is contained in:
2025-03-05 16:27:52 +00:00
parent de27df1aff
commit 26d72f2f45
7 changed files with 157 additions and 18 deletions

View File

@ -207,7 +207,7 @@ pub struct CreateVmRequest {
pub template_id: u64, pub template_id: u64,
pub image_id: u64, pub image_id: u64,
pub ssh_key_id: u64, pub ssh_key_id: u64,
pub ref_code: Option<String> pub ref_code: Option<String>,
} }
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]

View File

@ -2,7 +2,7 @@ use crate::api::model::{
AccountPatchRequest, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, AccountPatchRequest, ApiUserSshKey, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus,
ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest, ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest,
}; };
use crate::host::{get_host_client, FullVmInfo}; use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData};
use crate::nip98::Nip98Auth; use crate::nip98::Nip98Auth;
use crate::provisioner::{HostCapacityService, LNVpsProvisioner}; use crate::provisioner::{HostCapacityService, LNVpsProvisioner};
use crate::settings::Settings; use crate::settings::Settings;
@ -42,7 +42,8 @@ pub fn routes() -> Vec<Route> {
v1_start_vm, v1_start_vm,
v1_stop_vm, v1_stop_vm,
v1_restart_vm, v1_restart_vm,
v1_patch_vm v1_patch_vm,
v1_time_series
] ]
} }
@ -229,7 +230,7 @@ async fn v1_patch_vm(
if let Some(ptr) = &data.reverse_dns { if let Some(ptr) = &data.reverse_dns {
let mut ips = db.list_vm_ip_assignments(vm.id).await?; let mut ips = db.list_vm_ip_assignments(vm.id).await?;
for mut ip in ips.iter_mut() { for ip in ips.iter_mut() {
ip.dns_reverse = Some(ptr.to_string()); ip.dns_reverse = Some(ptr.to_string());
provisioner.update_reverse_ip_dns(ip).await?; provisioner.update_reverse_ip_dns(ip).await?;
db.update_vm_ip_assignment(ip).await?; db.update_vm_ip_assignment(ip).await?;
@ -473,6 +474,26 @@ async fn v1_restart_vm(
ApiData::ok(()) ApiData::ok(())
} }
#[openapi(tag = "VM")]
#[get("/api/v1/vm/<id>/time-series")]
async fn v1_time_series(
auth: Nip98Auth,
db: &State<Arc<dyn LNVpsDb>>,
settings: &State<Settings>,
id: u64,
) -> ApiResult<Vec<TimeSeriesData>> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = db.upsert_user(&pubkey).await?;
let vm = db.get_vm(id).await?;
if uid != vm.user_id {
return ApiData::err("VM does not belong to you");
}
let host = db.get_host(vm.host_id).await?;
let client = get_host_client(&host, &settings.provisioner)?;
ApiData::ok(client.get_time_series_data(&vm, TimeSeries::Hourly).await?)
}
/// Get payment status (for polling) /// Get payment status (for polling)
#[openapi(tag = "Payment")] #[openapi(tag = "Payment")]
#[get("/api/v1/payment/<id>")] #[get("/api/v1/payment/<id>")]

View File

@ -27,7 +27,11 @@ impl Cloudflare {
"Error updating record: {:?}", "Error updating record: {:?}",
rsp.errors rsp.errors
.as_ref() .as_ref()
.map(|e| e.iter().map(|i| i.message.clone()).collect::<Vec<String>>().join(", ")) .map(|e| e
.iter()
.map(|i| i.message.clone())
.collect::<Vec<String>>()
.join(", "))
.unwrap_or_default() .unwrap_or_default()
); );
} }

View File

@ -1,4 +1,4 @@
use crate::host::{FullVmInfo, VmHostClient}; use crate::host::{FullVmInfo, TimeSeries, TimeSeriesData, VmHostClient};
use crate::status::VmState; use crate::status::VmState;
use lnvps_db::{async_trait, Vm, VmOsImage}; use lnvps_db::{async_trait, Vm, VmOsImage};
@ -37,4 +37,12 @@ impl VmHostClient for LibVirt {
async fn configure_vm(&self, vm: &Vm) -> anyhow::Result<()> { async fn configure_vm(&self, vm: &Vm) -> anyhow::Result<()> {
todo!() todo!()
} }
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> anyhow::Result<Vec<TimeSeriesData>> {
todo!()
}
} }

View File

@ -6,6 +6,8 @@ use lnvps_db::{
async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmHost, VmHostDisk, VmHostKind, VmIpAssignment, async_trait, IpRange, LNVpsDb, UserSshKey, Vm, VmHost, VmHostDisk, VmHostKind, VmIpAssignment,
VmOsImage, VmTemplate, VmOsImage, VmTemplate,
}; };
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@ -40,6 +42,13 @@ pub trait VmHostClient: Send + Sync {
/// Apply vm configuration (patch) /// Apply vm configuration (patch)
async fn configure_vm(&self, cfg: &FullVmInfo) -> Result<()>; async fn configure_vm(&self, cfg: &FullVmInfo) -> Result<()>;
/// Get resource usage data
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> Result<Vec<TimeSeriesData>>;
} }
pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> { pub fn get_host_client(host: &VmHost, cfg: &ProvisionerConfig) -> Result<Arc<dyn VmHostClient>> {
@ -118,3 +127,24 @@ impl FullVmInfo {
}) })
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TimeSeriesData {
pub timestamp: u64,
pub cpu: f32,
pub memory: f32,
pub memory_size: u64,
pub net_in: f32,
pub net_out: f32,
pub disk_write: f32,
pub disk_read: f32,
}
#[derive(Debug, Clone)]
pub enum TimeSeries {
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}

View File

@ -1,4 +1,4 @@
use crate::host::{FullVmInfo, VmHostClient}; use crate::host::{FullVmInfo, TimeSeries, TimeSeriesData, VmHostClient};
use crate::json_api::JsonApi; use crate::json_api::JsonApi;
use crate::settings::{QemuConfig, SshConfig}; use crate::settings::{QemuConfig, SshConfig};
use crate::ssh_client::SshClient; use crate::ssh_client::SshClient;
@ -164,6 +164,22 @@ impl ProxmoxClient {
} }
} }
pub async fn get_vm_rrd_data(
&self,
id: ProxmoxVmId,
timeframe: &str,
) -> Result<Vec<RrdDataPoint>> {
let data: ResponseBase<Vec<_>> = self
.api
.get(&format!(
"/api2/json/nodes/{}/qemu/{}/rrddata?timeframe={}",
&self.node, id, timeframe
))
.await?;
Ok(data.data)
}
/// Get the current status of a running task /// Get the current status of a running task
/// ///
/// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/tasks/{upid}/status /// https://pve.proxmox.com/pve-docs/api-viewer/?ref=public_apis#/nodes/{node}/tasks/{upid}/status
@ -480,16 +496,15 @@ impl VmHostClient for ProxmoxClient {
self.wait_for_task(&t_create).await?; self.wait_for_task(&t_create).await?;
// import primary disk from image (scsi0) // import primary disk from image (scsi0)
self self.import_disk_image(ImportDiskImageRequest {
.import_disk_image(ImportDiskImageRequest { vm_id,
vm_id, node: self.node.clone(),
node: self.node.clone(), storage: req.disk.name.clone(),
storage: req.disk.name.clone(), disk: "scsi0".to_string(),
disk: "scsi0".to_string(), image: req.image.filename()?,
image: req.image.filename()?, is_ssd: matches!(req.disk.kind, DiskType::SSD),
is_ssd: matches!(req.disk.kind, DiskType::SSD), })
}) .await?;
.await?;
// resize disk to match template // resize disk to match template
let j_resize = self let j_resize = self
@ -549,6 +564,26 @@ impl VmHostClient for ProxmoxClient {
.await?; .await?;
Ok(()) Ok(())
} }
async fn get_time_series_data(
&self,
vm: &Vm,
series: TimeSeries,
) -> Result<Vec<TimeSeriesData>> {
let r = self
.get_vm_rrd_data(
vm.id.into(),
match series {
TimeSeries::Hourly => "hour",
TimeSeries::Daily => "day",
TimeSeries::Weekly => "week",
TimeSeries::Monthly => "month",
TimeSeries::Yearly => "year",
},
)
.await?;
Ok(r.into_iter().map(TimeSeriesData::from).collect())
}
} }
/// Wrap a database vm id /// Wrap a database vm id
@ -901,3 +936,43 @@ pub struct VmConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub serial_0: Option<String>, pub serial_0: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RrdDataPoint {
pub time: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<f32>,
#[serde(rename = "mem")]
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<f32>,
#[serde(rename = "maxmem")]
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_size: Option<u64>,
#[serde(rename = "netin")]
#[serde(skip_serializing_if = "Option::is_none")]
pub net_in: Option<f32>,
#[serde(rename = "netout")]
#[serde(skip_serializing_if = "Option::is_none")]
pub net_out: Option<f32>,
#[serde(rename = "diskwrite")]
#[serde(skip_serializing_if = "Option::is_none")]
pub disk_write: Option<f32>,
#[serde(rename = "diskread")]
#[serde(skip_serializing_if = "Option::is_none")]
pub disk_read: Option<f32>,
}
impl From<RrdDataPoint> for TimeSeriesData {
fn from(value: RrdDataPoint) -> Self {
Self {
timestamp: value.time,
cpu: value.cpu.unwrap_or(0.0),
memory: value.memory.unwrap_or(0.0),
memory_size: value.memory_size.unwrap_or(0),
net_in: value.net_in.unwrap_or(0.0),
net_out: value.net_out.unwrap_or(0.0),
disk_write: value.disk_write.unwrap_or(0.0),
disk_read: value.disk_read.unwrap_or(0.0),
}
}
}

View File

@ -80,7 +80,8 @@ impl HostCapacityService {
.filter_map(|v| { .filter_map(|v| {
templates templates
.iter() .iter()
.find(|t| t.id == v.template_id).map(|t| (v.id, t)) .find(|t| t.id == v.template_id)
.map(|t| (v.id, t))
}) })
.collect(); .collect();