App Monitor Server con .NET MAUI

Como es costumbre, en los primeros laboratorios de aplicaciones móviles se suele desarrollar la clásica calculadora o el clásico menú de restaurante. Sí, seguramente es lo primero que encontrarás en internet o incluso en algunos libros. En esta ocasión desarrollaremos una app un poco superior incluyendo API Rest, conexión SSH a un servidor Linux y la tecnología de .NET MAUI con Visual Studio.

Es un proyecto súper sencillo, pero que se puede complicar si no se tienen claros los conceptos desarrollados en los ciclos anteriores, Iniciemos:

Paso 1 – Objetivo

Desarrollar una aplicación móvil para Android que permita monitorear los principales recursos de un servidor remoto.

Consideraciones:

Servidor en red: Nuestra app debe ser ejecutada en un dispositivo que comparta la misma red, salvo que saquemos la API a un endpoint público mediante una IP privada o algún servicio como Ngrok.

Sin certificado SSL: Al tratarse de un laboratorio base, no se considerará el uso de certificado SSL, motivo por el cual deberemos ajustar cierta política en nuestra app. Ya sabemos cómo Android detesta los recursos sin certificado.

Servidor virtualizado: Si el servidor es virtualizado, considerar colocar la tarjeta virtual en modo puente a fin de colocar la MV en la misma red de nuestros dispositivos. Obviamente, asegurar que nuestro DHCP le asigne una IP o asignarle una IP del segmento manualmente ustedes mismos.

Paso 2 – Monitor Python en el Servidor

Mediante Python recogeremos los datos de nuestro servidor y los expondremos mediante FastApi.

Conectate a tu servidor mediante protocolo ssh:

ssh -p22 realdeb@192.168.56.110

Creamos una carpeta para nuestro script en Python.

mkdir monitor_server
cd monitor_server

Dentro de nuestra carpeta monitor_server creamos nuestro script Python monitor_api.py con el siguiente contenido:

import psutil
import platform
import socket
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime
import os

app = FastAPI(title="MonitorServer API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], 
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

def get_linux_distro():
    """Lee os-release para obtener el nombre exacto de la distribución."""
    if os.path.exists("/etc/os-release"):
        with open("/etc/os-release", "r") as f:
            for line in f:
                if line.startswith("PRETTY_NAME="):
                    return line.split("=")[1].strip().strip('"')
    return f"{platform.system()} {platform.release()}"

def get_top_processes(limit=10):
    procesos = []
    for proc in sorted(psutil.process_iter(['pid', 'name', 'memory_percent', 'username']), 
                       key=lambda p: p.info['memory_percent'] or 0, 
                       reverse=True)[:limit]:
        try:
            procesos.append({
                "pid": proc.info['pid'],
                "name": proc.info['name'],
                "user": proc.info['username'],
                "mem_percent": round(proc.info['memory_percent'] or 0, 2)
            })
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    return procesos

@app.get("/api/status")
def get_server_status():
    ram = psutil.virtual_memory()
    return {
        "system": {
            "os": get_linux_distro(),
            "hostname": socket.gethostname(),
            "boot_time": datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S")
        },
        "cpu": {
            "cores": psutil.cpu_count(logical=True),
            "usage_percent": psutil.cpu_percent(interval=0.1)
        },
        "memory": {
            "total_gb": round(ram.total / (1024**3), 2),
            "used_gb": round(ram.used / (1024**3), 2),
            "usage_percent": ram.percent
        },
        "disk": {
            "usage_percent": psutil.disk_usage('/').percent
        },
        "users": [user.name for user in psutil.users()],
        "top_processes": get_top_processes(10)
    }

Aseguramos las dependencias:

pip install fastapi uvicorn psutil

Permitimos la salida del puerto 8000 en el Firewall de nuestro server y ejecutamos el Monitor Python (monitor_api.py). Para este laboratorio estamos trabajando sobre Rocky Linux.

# Abrir puerto en Rocky Linux
sudo firewall-cmd --zone=public --add-port=8000/tcp --permanent
sudo firewall-cmd --reload

# Ejecutar el servidor
uvicorn server_monitor:app --host 0.0.0.0 --port 8000

Verificamos en un navegador desde otro equipo de la red para confirmar que nuestro endpoint esta funcionando correctamente:

http://192.168.56.110:8000/api/status

Paso 3 – Construir App Nativa para Android

Construiremos nuestra App en Visual Studio 2026 con .NET MAUI. Instalamos los recursos.

Al concluir la instalación se recomienda logearse con su cuenda educativa:

Creamos una carpeta en el escritorio o donde deseemos y creamos nuestro proyecto.

Nombra el proyecto exactamente como MonitorServer

2.1 Permisos de Red para Android

Modifica la etiqueta <application> para permitir tráfico HTTP local añadiendo android:usesCleartextTraffic="true"

En el Explorador de soluciones, ve a Platforms -> Android -> AndroidManifest.xml.

android:usesCleartextTraffic="true"

2.2 Modelos de Datos en C# (Deserialización)

Para que MAUI entienda el JSON de Python, crearemos las clases espejo.

Haz clic derecho en el proyecto MonitorServer -> Agregar -> Nueva carpeta, llámala Models.

Dentro de Models, crea una clase llamada ServerData.cs

Dentro de la clase ServerData.cs borra su contenido y pega este código:

using System.Text.Json.Serialization;

namespace MonitorServer.Models;

public class SystemInfo
{
    [JsonPropertyName("os")] public string Os { get; set; }
    [JsonPropertyName("hostname")] public string Hostname { get; set; }
    [JsonPropertyName("boot_time")] public string BootTime { get; set; }
}

public class CpuInfo
{
    [JsonPropertyName("cores")] public int Cores { get; set; }
    [JsonPropertyName("usage_percent")] public double UsagePercent { get; set; }
}

public class MemoryInfo
{
    [JsonPropertyName("total_gb")] public double TotalGb { get; set; }
    [JsonPropertyName("used_gb")] public double UsedGb { get; set; }
    [JsonPropertyName("usage_percent")] public double UsagePercent { get; set; }
}

public class DiskInfo
{
    [JsonPropertyName("usage_percent")] public double UsagePercent { get; set; }
}

public class ProcessInfo
{
    [JsonPropertyName("pid")] public int Pid { get; set; }
    [JsonPropertyName("name")] public string Name { get; set; }
    [JsonPropertyName("user")] public string User { get; set; }
    [JsonPropertyName("mem_percent")] public double MemPercent { get; set; }
}

public class ServerStatus
{
    [JsonPropertyName("system")] public SystemInfo System { get; set; }
    [JsonPropertyName("cpu")] public CpuInfo Cpu { get; set; }
    [JsonPropertyName("memory")] public MemoryInfo Memory { get; set; }
    [JsonPropertyName("disk")] public DiskInfo Disk { get; set; }
    [JsonPropertyName("users")] public List<string> Users { get; set; }
    [JsonPropertyName("top_processes")] public List<ProcessInfo> TopProcesses { get; set; }
}

2.3 La Interfaz Nativa (XAML)

Reemplaza todo el contenido de tu archivo MainPage.xaml con el siguiente código. Nota que hemos incluido Shell.NavBarIsVisible="False" para crear una experiencia inmersiva de terminal.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MonitorServer.MainPage"
             BackgroundColor="#0d0d0d"
             Shell.NavBarIsVisible="False">

    <ScrollView Padding="15">
        <VerticalStackLayout Spacing="20">

            <Label Text="> Monitor Server " TextColor="#00ff41" FontFamily="monospace" FontSize="22" FontAttributes="Bold" />
            <BoxView HeightRequest="2" Color="#008f11" Margin="0,-10,0,0"/>

            <Label x:Name="ErrorLabel" Text="!! FATAL ERROR: CONNECTION LOST !!" TextColor="Red" FontFamily="monospace" IsVisible="False" />

            <Border Stroke="#008f11" StrokeThickness="1" BackgroundColor="#051505" Padding="15">
                <VerticalStackLayout Spacing="8">
                    <Label Text="[ SYSTEM_INFO ]" TextColor="White" FontFamily="monospace" FontAttributes="Bold" Margin="0,0,0,10"/>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="OS:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblOs" Text="--" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="HOSTNAME:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblHost" Text="--" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="BOOT_TIME:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblBoot" Text="--" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                </VerticalStackLayout>
            </Border>

            <Border Stroke="#008f11" StrokeThickness="1" BackgroundColor="#051505" Padding="15">
                <VerticalStackLayout Spacing="8">
                    <Label Text="[ HARDWARE_STATS ]" TextColor="White" FontFamily="monospace" FontAttributes="Bold" Margin="0,0,0,10"/>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="CPU_USAGE:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblCpu" Text="-- %" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="RAM_USAGE:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblRamUsage" Text="-- %" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="RAM_USED:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblRamUsed" Text="-- GB" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                    <Grid ColumnDefinitions="Auto, *">
                        <Label Grid.Column="0" Text="DISK_USAGE:" TextColor="White" FontFamily="monospace" FontAttributes="Bold"/>
                        <Label Grid.Column="1" x:Name="LblDisk" Text="-- %" TextColor="#00ff41" FontFamily="monospace" HorizontalOptions="End"/>
                    </Grid>
                </VerticalStackLayout>
            </Border>

            <Border Stroke="#008f11" StrokeThickness="1" BackgroundColor="#051505" Padding="15">
                <VerticalStackLayout Spacing="8">
                    <Label Text="[ TOP_PROCESSES ]" TextColor="White" FontFamily="monospace" FontAttributes="Bold" Margin="0,0,0,10"/>
                    <Grid ColumnDefinitions="50, 70, *, 60" Margin="0,0,0,5">
                        <Label Grid.Column="0" Text="PID" TextColor="White" FontFamily="monospace" FontAttributes="Bold" FontSize="12"/>
                        <Label Grid.Column="1" Text="USER" TextColor="White" FontFamily="monospace" FontAttributes="Bold" FontSize="12"/>
                        <Label Grid.Column="2" Text="NAME" TextColor="White" FontFamily="monospace" FontAttributes="Bold" FontSize="12"/>
                        <Label Grid.Column="3" Text="MEM" TextColor="White" FontFamily="monospace" FontAttributes="Bold" FontSize="12" HorizontalTextAlignment="End"/>
                    </Grid>
                    <BoxView HeightRequest="1" Color="#008f11" Margin="0,0,0,5"/>
                    <CollectionView x:Name="ProcesosList" HeightRequest="250">
                        <CollectionView.ItemTemplate>
                            <DataTemplate>
                                <Grid ColumnDefinitions="50, 70, *, 60" Padding="0,5">
                                    <Label Grid.Column="0" Text="{Binding Pid}" TextColor="#00ff41" FontFamily="monospace" FontSize="12"/>
                                    <Label Grid.Column="1" Text="{Binding User}" TextColor="#00ff41" FontFamily="monospace" FontSize="12"/>
                                    <Label Grid.Column="2" Text="{Binding Name}" TextColor="#00ff41" FontFamily="monospace" FontSize="12" LineBreakMode="TailTruncation"/>
                                    <Label Grid.Column="3" Text="{Binding MemPercent, StringFormat='{0}%'}" TextColor="#00ff41" FontFamily="monospace" FontSize="12" HorizontalTextAlignment="End"/>
                                </Grid>
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>
                </VerticalStackLayout>
            </Border>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

Fase 5: Consumo de la API (C# Code-Behind)

Reemplaza todo el contenido del archivo MainPage.xaml.cs por el siguiente y no olvidar que debes cambiar IP_DEL_SERVIDOR por la dirección IP real de tu servidor.

using System.Text.Json;
using MonitorServer.Models;

namespace MonitorServer;

public partial class MainPage : ContentPage
{
    // ¡REEMPLAZAR CON LA IP REAL DEL SERVIDOR ROCKY LINUX!
    private const string ApiUrl = "http://IP_DEL_SERVIDOR:8000/api/status";
    
    private readonly HttpClient _httpClient;
    private IDispatcherTimer _timer;

    public MainPage()
    {
        InitializeComponent();
        
        _httpClient = new HttpClient();
        _httpClient.Timeout = TimeSpan.FromSeconds(5);

        _timer = Application.Current.Dispatcher.CreateTimer();
        _timer.Interval = TimeSpan.FromSeconds(2);
        _timer.Tick += (s, e) => _ = FetchDataAsync();
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();
        _ = FetchDataAsync();
        _timer.Start();
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();
        _timer.Stop(); 
    }

    private async Task FetchDataAsync()
    {
        try
        {
            var response = await _httpClient.GetStringAsync(ApiUrl);
            var serverData = JsonSerializer.Deserialize<ServerStatus>(response);

            if (serverData != null)
            {
                MainThread.BeginInvokeOnMainThread(() =>
                {
                    ErrorLabel.IsVisible = false;

                    LblOs.Text = serverData.System.Os;
                    LblHost.Text = serverData.System.Hostname;
                    LblBoot.Text = serverData.System.BootTime;

                    LblCpu.Text = $"{serverData.Cpu.UsagePercent} %";
                    LblRamUsage.Text = $"{serverData.Memory.UsagePercent} %";
                    LblRamUsed.Text = $"{serverData.Memory.UsedGb} / {serverData.Memory.TotalGb} GB";
                    LblDisk.Text = $"{serverData.Disk.UsagePercent} %";

                    ProcesosList.ItemsSource = serverData.TopProcesses;
                });
            }
        }
        catch (Exception ex)
        {
            MainThread.BeginInvokeOnMainThread(() =>
            {
                ErrorLabel.IsVisible = true;
                System.Diagnostics.Debug.WriteLine($"Error de conexión: {ex.Message}");
            });
        }
    }
}

Paso 4 – Compilar / Ejecutar

Habilitamos el modo desarrollador en el dispositivo Android y activamos el modo «Depuración por USB»

Conectamos el móvil al computador por usb y autorizamos la huella:

Ejecutamos y verificamos en el dispotivo Android:

Detalles Técnicos

El Lado del Servidor (Rocky Linux)

monitor_api.py (El Recolector y Exponedor)

Es el corazón del backend. Un script de Python que actúa como un agente de monitoreo.

Utiliza la librería psutil para «hablar» con el núcleo (kernel) de Linux y preguntarle cuánta memoria se está usando, cómo están los núcleos del procesador y qué programas están consumiendo más recursos.

No solo lee los datos, sino que usa FastAPI para empaquetarlos en formato JSON y abrir una puerta (el puerto 8000) para que cualquier dispositivo en la red pueda solicitar esa información a través del endpoint (/api/status).


El Lado de la Aplicación Móvil (.NET MAUI)

AndroidManifest.xml (El Permiso de Seguridad)

Es el archivo de configuración maestro que Android lee antes de instalar cualquier aplicación.

Le dice al sistema operativo qué permisos necesita la app. En nuestro caso, agregamos android:usesCleartextTraffic="true".

Por defecto, Android es muy estricto y bloquea cualquier conexión a internet que no sea segura (HTTPS). Como nuestro servidor Python en desarrollo usa HTTP básico, tuvimos que modificar este archivo para que Android no bloqueara la conexión y nos permitiera ver los datos.

Models/ServerData.cs (El Traductor)

Un conjunto de clases en C# que definen la «forma» de los datos.

Actúa como un molde. C# es un lenguaje fuertemente tipado (necesita reglas estrictas). Cuando el texto JSON llega desde Python, C# no sabe qué hacer con él. Estas clases usan etiquetas como [JsonPropertyName("cpu")] para decirle a C#: «Toma el valor ‘cpu’ del JSON y guárdalo exactamente en esta variable». A este proceso se le llama deserialización.

MainPage.xaml (La Cara o «Vista»)

Es el archivo de diseño donde construimos la interfaz gráfica de usuario (UI) usando XAML (un lenguaje de marcado similar a HTML pero para apps nativas).

Define dónde va cada texto, los colores oscuros, los bordes verdes y el tamaño de la letra.

Aquí configuramos el Data Binding (enlace de datos). Al escribir cosas como {Binding Name} dentro de la lista de procesos, le decimos a la interfaz que se conecte automáticamente con el «Traductor» (ServerData.cs) y se dibuje a sí misma sin que tengamos que programar la tabla fila por fila. Además, aquí ocultamos la barra superior para dar el efecto de terminal inmersiva.

MainPage.xaml.cs (El Cerebro o «Code-Behind»)

Es el archivo C# que está «detrás» de la vista visual. Contiene la lógica de comportamiento de esa pantalla específica.

Tiene tres tareas vitales:

  1. Usa HttpClient para ir por la red hasta la IP de tu servidor y descargar el JSON.
  2. Usa un temporizador (IDispatcherTimer) para repetir esta acción como un bucle infinito cada 2 segundos.
  3. Toma los datos traducidos y los inyecta en los elementos visuales del XAML asegurándose de hacerlo en el «hilo principal» (MainThread) para que la pantalla del celular no se congele mientras descarga la información.

Walther Curo De La Cruz
Instructor