feat: HELIOS Remote v5.0.0 Final Release & Systemd Auto-Start Daemon
Some checks failed
HELIOS Remote CI/CD Pipeline / Check & Test Workspace (push) Has been cancelled
HELIOS Remote CI/CD Pipeline / Build Release Binaries (push) Has been cancelled

This commit is contained in:
2026-07-26 20:07:09 +09:00
parent be8c4329ca
commit c11afbedd1
58 changed files with 6454 additions and 0 deletions

2
server/.env Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL=postgres://helios_user:helios_password@localhost:5433/helios_db
JWT_SECRET=super_secret_key_for_testing_only_1234567890

24
server/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[[bin]]
name = "migrate_test"
path = "src/migrate_test.rs"
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] }
jsonwebtoken = "9.0"
dotenvy = "0.15"
tower-http = { version = "0.5", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"
bcrypt = "0.15"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
common = { path = "../common" }

View File

@@ -0,0 +1,25 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
username TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Function to update updated_at column automatically
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();

View File

@@ -0,0 +1,26 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Function to update updated_at column automatically
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS update_todos_updated_at ON todos;
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON todos
FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
hostname VARCHAR(255),
ip_address VARCHAR(45),
status VARCHAR(50) NOT NULL DEFAULT 'offline',
last_heartbeat TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);

63
server/src/auth.rs Normal file
View File

@@ -0,0 +1,63 @@
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::env;
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub exp: usize,
}
pub struct AuthUser(pub Claims);
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".to_string()))?;
if !auth_header.starts_with("Bearer ") {
return Err((StatusCode::UNAUTHORIZED, "Invalid token type".to_string()));
}
let token = &auth_header[7..];
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".to_string()))?;
Ok(AuthUser(token_data.claims))
}
}
pub fn create_jwt(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::hours(24))
.expect("valid timestamp")
.timestamp() as usize;
let claims = Claims {
sub: user_id.to_owned(),
exp: expiration,
};
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
}

View File

@@ -0,0 +1,89 @@
use axum::{
extract::State,
http::StatusCode,
Json,
};
use crate::auth::create_jwt;
use crate::models::{AuthResponse, LoginRequest, RegisterRequest, User};
use crate::AppState;
use bcrypt::{hash, verify, DEFAULT_COST};
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), (StatusCode, String)> {
// 1. Check if user already exists
let existing_user = sqlx::query_as::<_, User>(
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE email = $1",
)
.bind(&payload.email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing_user.is_some() {
return Err((StatusCode::CONFLICT, "Email already registered".to_string()));
}
// 2. Hash password
let password_hash = hash(payload.password, DEFAULT_COST)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Create user
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (email, username, password_hash) VALUES ($1, $2, $3) RETURNING id, email, password_hash, username, created_at, updated_at",
)
.bind(&payload.email)
.bind(&payload.username)
.bind(&password_hash)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Create JWT
let token = create_jwt(&user.id.to_string())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((
StatusCode::CREATED,
Json(AuthResponse {
token,
user,
}),
))
}
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<(StatusCode, Json<AuthResponse>), (StatusCode, String)> {
// 1. Find user by email
let user = sqlx::query_as::<_, User>(
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE email = $1",
)
.bind(&payload.email)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string()))?;
// 2. Verify password
let is_valid = verify(payload.password, &user.password_hash)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !is_valid {
return Err((StatusCode::UNAUTHORIZED, "Invalid email or password".to_string()));
}
// 3. Create JWT
let token = create_jwt(&user.id.to_string())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((
StatusCode::OK,
Json(AuthResponse {
token,
user,
}),
))
}

View File

@@ -0,0 +1,134 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::models::{Device, HeartbeatResponse, RegisterDeviceRequest};
use crate::AppState;
/// Register a new remote device for the authenticated user
pub async fn register_device(
AuthUser(claims): AuthUser,
State(state): State<AppState>,
Json(payload): Json<RegisterDeviceRequest>,
) -> Result<(StatusCode, Json<Device>), (StatusCode, String)> {
let user_id = claims
.sub
.parse::<Uuid>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
let device = sqlx::query_as::<_, Device>(
r#"
INSERT INTO devices (user_id, name, hostname, ip_address, status)
VALUES ($1, $2, $3, $4, 'offline')
RETURNING id, user_id, name, hostname, ip_address, status, last_heartbeat, created_at, updated_at
"#,
)
.bind(user_id)
.bind(&payload.name)
.bind(&payload.hostname)
.bind(&payload.ip_address)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok((StatusCode::CREATED, Json(device)))
}
/// List all devices registered by the authenticated user
pub async fn list_devices(
AuthUser(claims): AuthUser,
State(state): State<AppState>,
) -> Result<Json<Vec<Device>>, (StatusCode, String)> {
let user_id = claims
.sub
.parse::<Uuid>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
let devices = sqlx::query_as::<_, Device>(
r#"
SELECT id, user_id, name, hostname, ip_address, status, last_heartbeat, created_at, updated_at
FROM devices
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(devices))
}
/// Update last heartbeat and set status to 'online'
pub async fn agent_heartbeat(
AuthUser(claims): AuthUser,
State(state): State<AppState>,
Path(device_id): Path<Uuid>,
) -> Result<Json<HeartbeatResponse>, (StatusCode, String)> {
let user_id = claims
.sub
.parse::<Uuid>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
let now = Utc::now();
let result = sqlx::query(
r#"
UPDATE devices
SET status = 'online', last_heartbeat = $1, updated_at = $1
WHERE id = $2 AND user_id = $3
"#,
)
.bind(now)
.bind(device_id)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Device not found or unauthorized".to_string()));
}
Ok(Json(HeartbeatResponse {
success: true,
status: "online".to_string(),
last_heartbeat: now,
}))
}
/// Delete a registered device
pub async fn delete_device(
AuthUser(claims): AuthUser,
State(state): State<AppState>,
Path(device_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = claims
.sub
.parse::<Uuid>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID in token".to_string()))?;
let result = sqlx::query(
r#"
DELETE FROM devices
WHERE id = $1 AND user_id = $2
"#,
)
.bind(device_id)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Device not found or unauthorized".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,5 @@
pub mod auth_handler;
pub mod user_handler;
pub mod device_handler;
pub mod todo;

View File

@@ -0,0 +1 @@
pub mod todo_handler;

View File

@@ -0,0 +1,145 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::auth::AuthUser;
use crate::models::{CreateTodoRequest, Todo, UpdateTodoRequest};
use crate::AppState;
use uuid::Uuid;
pub async fn create_todo(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
Json(payload): Json<CreateTodoRequest>,
) -> Result<Json<Todo>, (StatusCode, String)> {
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
let todo = sqlx::query_as::<_, Todo>(
"INSERT INTO todos (user_id, title, description) VALUES ($1, $2, $3) RETURNING id, user_id, title, description, is_completed, created_at, updated_at",
)
.bind(user_id)
.bind(&payload.title)
.bind(&payload.description)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(todo))
}
pub async fn list_todos(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
) -> Result<Json<Vec<Todo>>, (StatusCode, String)> {
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
let todos = sqlx::query_as::<_, Todo>(
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(todos))
}
pub async fn update_todo(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
Path(todo_id): Path<Uuid>,
Json(payload): Json<UpdateTodoRequest>,
) -> Result<Json<Todo>, (StatusCode, String)> {
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
// 1. Check if todo exists and belongs to user
let _existing_todo = sqlx::query_as::<_, Todo>(
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE id = $1 AND user_id = $2",
)
.bind(todo_id)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Todo not found".to_string()))?;
// 2. Update fields dynamically
let mut query_builder = sqlx::QueryBuilder::new("UPDATE todos SET ");
let mut has_updates = false;
if let Some(ref title) = payload.title {
if has_updates {
query_builder.push(", ");
}
query_builder.push("title = ");
query_builder.push_bind(title);
has_updates = true;
}
if let Some(ref description) = payload.description {
if has_updates {
query_builder.push(", ");
}
query_builder.push("description = ");
query_builder.push_bind(description);
has_updates = true;
}
if let Some(is_completed) = payload.is_completed {
if has_updates {
query_builder.push(", ");
}
query_builder.push("is_completed = ");
query_builder.push_bind(is_completed);
has_updates = true;
}
if !has_updates {
return Err((StatusCode::BAD_REQUEST, "No fields to update".to_string()));
}
// Add updated_at to the end of the SET clause
query_builder.push(", updated_at = NOW()");
query_builder.push(" WHERE id = ").push_bind(todo_id).push(" AND user_id = ").push_bind(user_id);
// DEBUG: Print the generated SQL
let sql = query_builder.sql();
tracing::info!("Generated SQL: {}", sql);
query_builder.build().execute(&state.db).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Return updated todo
let updated_todo = sqlx::query_as::<_, Todo>(
"SELECT id, user_id, title, description, is_completed, created_at, updated_at FROM todos WHERE id = $1",
)
.bind(todo_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(updated_todo))
}
pub async fn delete_todo(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
Path(todo_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = claims.sub.parse::<Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
let result = sqlx::query(
"DELETE FROM todos WHERE id = $1 AND user_id = $2",
)
.bind(todo_id)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Todo not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,52 @@
use axum::{
extract::State,
http::StatusCode,
Json,
};
use crate::auth::AuthUser;
use crate::models::{UpdateUserRequest, User};
use crate::AppState;
pub async fn get_me(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
) -> Result<Json<User>, (StatusCode, String)> {
let user = sqlx::query_as::<_, User>(
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE id = $1",
)
.bind(claims.sub.parse::<uuid::Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(user))
}
pub async fn update_me(
State(state): State<AppState>,
AuthUser(claims): AuthUser,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<User>, (StatusCode, String)> {
let user_id = claims.sub.parse::<uuid::Uuid>().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UUID format".to_string()))?;
if let Some(new_username) = payload.username {
sqlx::query(
"UPDATE users SET username = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&new_username)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
let updated_user = sqlx::query_as::<_, User>(
"SELECT id, email, password_hash, username, created_at, updated_at FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(updated_user))
}

75
server/src/main.rs Normal file
View File

@@ -0,0 +1,75 @@
use axum::{
extract::State,
routing::{get, post, patch, delete},
Router,
};
use sqlx::postgres::PgPool;
use std::net::SocketAddr;
use tracing::info;
use dotenvy::dotenv;
mod auth;
mod handlers;
mod models;
use crate::auth::AuthUser;
use crate::handlers::auth_handler::{login, register};
use crate::handlers::user_handler::{get_me, update_me};
use crate::handlers::device_handler::{register_device, list_devices, agent_heartbeat, delete_device};
use crate::handlers::todo::todo_handler::{create_todo, delete_todo, list_todos, update_todo};
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() {
// Initialize tracing
tracing_subscriber::fmt::init();
dotenv().ok();
// Database connection pool
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPool::connect(&database_url)
.await
.expect("Failed to connect to Postgres");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run DB migrations");
let state = AppState { db: pool };
// Build our application with state
let app = Router::new()
.route("/", get(handler))
.route("/auth/register", post(register))
.route("/auth/login", post(login))
.route("/auth/me", get(me))
.route("/users/me", get(get_me).patch(update_me))
.route("/devices", post(register_device).get(list_devices))
.route("/devices/:id/heartbeat", post(agent_heartbeat))
.route("/devices/:id", delete(delete_device))
.route("/todos", post(create_todo).get(list_todos))
.route("/todos/:id", patch(update_todo).delete(delete_todo))
.with_state(state);
// Run it with hyper on localhost:3000
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handler(State(state): State<AppState>) -> &'static str {
let _ = &state.db;
"Hello, HELIOS!"
}
async fn me(AuthUser(claims): AuthUser) -> String {
format!("Welcome, user: {}", claims.sub)
}

View File

@@ -0,0 +1,16 @@
use sqlx::postgres::PgPool;
use std::env;
use dotenvy::dotenv;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPool::connect(&database_url).await?;
println!("Running migrations...");
sqlx::migrate!("./migrations").run(&pool).await?;
println!("Migrations completed successfully!");
Ok(())
}

90
server/src/models.rs Normal file
View File

@@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub username: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: User,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub username: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Todo {
pub id: Uuid,
pub user_id: Uuid,
pub title: String,
pub description: Option<String>,
pub is_completed: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTodoRequest {
pub title: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTodoRequest {
pub title: Option<String>,
pub description: Option<String>,
pub is_completed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Device {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
pub hostname: Option<String>,
pub ip_address: Option<String>,
pub status: String,
pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct RegisterDeviceRequest {
pub name: String,
pub hostname: Option<String>,
pub ip_address: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct HeartbeatResponse {
pub success: bool,
pub status: String,
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
}