Build a Service Health Monitor CLI
Build a Python CLI tool that checks HTTP endpoints and reports health status.
Introduction
30 Second Summary
When a website goes down, every second counts. Knowing which services are healthy and which need attention is a daily reality for anyone managing infrastructure.
In this project, you will build a command-line tool that checks the health of web services and displays their status in a color-coded table. You will learn Python skills that transfer directly to DevOps automation work.
What You'll Build
A Python CLI application that reads endpoint configurations from a JSON file, performs HTTP health checks with timeout handling, and displays results in a formatted, color-coded table.
By the end of this project, you'll have:
- A Python CLI application with check, add, and list subcommands built using Typer.
- A JSON configuration file that makes adding and removing monitored services trivial.
- Practical Python skills including virtual environments, package management, HTTP requests, and error handling.
- Secret Mission: Add TCP port checking so you can monitor non-HTTP services like databases and caches.
Are there any prerequisites?
This project assumes basic scripting experience and familiarity with the command line. You should already have Python 3.12 and VS Code installed on your machine.
Set Up Your Python Project
Before writing any code, you need a clean workspace with the right tools ready. A virtual environment keeps this project's packages isolated from your system Python, so nothing you install here interferes with other projects.
In this step, you'll create a project folder, set up a virtual environment, install the two packages your CLI depends on, and create the starter files.
In this step, get ready to:
- Create a project folder and Python virtual environment.
- Install the typer and requests packages.
- Create the starter file structure for your CLI tool.
Create your project folder and virtual environment
A virtual environment is a self-contained Python installation inside your project folder. Packages you install here stay local to this project.
- In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir healthcheck
cd healthcheck
- Create and activate your virtual environment by running the commands for your operating system:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
python -m venv .venv
.venv\Scripts\activate
What does this do?
python -m venv .venv creates a .venv folder containing a fresh Python installation just for this project.
The activate command tells your terminal to use that isolated Python instead of the system-wide one. Any packages you install while the environment is active stay inside .venv.
- Confirm your virtual environment is active by checking your terminal prompt. You should see (.venv) at the beginning of the line.
Don't see (.venv) in your prompt?
- On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned then try the activate command again.
- On macOS, make sure you typed source before the path. Without it, the command won't work.
- Still stuck? Help me activate my Python virtual environment
Install the required packages
Your CLI needs two external packages. Typer handles command-line argument parsing using Python type hints. Requests handles making HTTP calls to the endpoints you want to monitor.
- Install both packages by running this command:
pip install typer requests
What gets installed?
typer automatically brings in Rich (for formatted terminal output), shellingham, and colorama on Windows. You'll use Rich later to create color-coded tables.
- Verify the installation by running:
pip list
- You should see typer, requests, rich, and several other dependencies in the output list.
Seeing a 'pip not found' error?
- Make sure your virtual environment is active. You should see (.venv) in your prompt before running pip.
- On macOS, try pip3 install typer requests if pip alone doesn't work.
- Still stuck? Help me fix a pip not found error in my Python virtual environment
Create the project file structure
Your project needs two files. monitor.py is the main CLI script that runs your health checks. services.json is the configuration file that stores which endpoints to monitor.
- Create both files by running the command for your operating system:
macOS
touch monitor.py services.json
Windows
New-Item -ItemType File -Path "monitor.py", "services.json"
- Confirm the files exist by running:
ls
- You should see monitor.py and services.json in the output (alongside the .venv folder).
- Open the healthcheck folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the healthcheck folder on your Desktop, then click Open.
Why open the folder in VS Code?
Opening the project folder (instead of individual files) gives VS Code full context about your project. It activates the virtual environment automatically in new terminals and enables features like file navigation in the sidebar.
Don't see your files in the sidebar?
- Make sure you opened the healthcheck folder itself, not the Desktop or a parent folder.
- Check the Explorer sidebar on the left. You should see monitor.py and services.json listed.
- Need help? Help me open my project folder in VS Code
Your project skeleton is ready. Next up, you'll write the core health check function that hits an HTTP endpoint and reports whether it's alive.
Build the Health Check Logic
Your project structure is ready. Now it is time to write the Python code that actually checks whether a service is running.
In this step, you will build the core function that sends an HTTP request to a URL and reports whether the service responded successfully or failed. You will also create helper functions for loading and saving your service configuration.
In this step, get ready to:
- Write a function that performs HTTP health checks with timeout and error handling.
- Write functions to load and save your service configuration from JSON.
- Test the health check logic against a live endpoint.
Write the check_endpoint function
Before writing any function, you need to import the libraries your code depends on and set up the module-level configuration.
- In VS Code, open monitor.py and add the following imports and setup code:
import json
import time
from pathlib import Path
import requests
import typer
from rich.console import Console
from rich.table import Table
# Create the Typer CLI app and Rich console for formatted output
app = typer.Typer(no_args_is_help=True)
console = Console()
# Path to the JSON config file that stores service definitions
CONFIG_FILE = Path("services.json")
What does this code do?
The first block imports standard library modules (json, time, Path) and the third-party packages you installed in Step 1 (requests, typer, rich).
The app object is your Typer CLI application. Setting no_args_is_help=True means running the tool without a subcommand automatically shows help text. CONFIG_FILE points to the JSON file that will store your monitored services.
- Save monitor.py.
You will not see a visible change yet. The next chunk adds the function that actually performs health checks.
Now you will write the function that sends an HTTP GET request to a URL, measures the response time, and catches common network failures gracefully.
- Below the CONFIG_FILE line in monitor.py, add the check_endpoint function and the main guard:
def check_endpoint(url: str, timeout: int = 5) -> dict:
"""Check an HTTP endpoint and return status information."""
try:
# Send a GET request with the configured timeout
response = requests.get(url, timeout=timeout)
return {
"status": "UP" if response.status_code < 400 else "DOWN",
"code": response.status_code,
"response_time_ms": round(response.elapsed.total_seconds() * 1000),
"error": None,
}
except requests.exceptions.Timeout:
# The server did not respond within the timeout window
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Timeout",
}
except requests.exceptions.ConnectionError:
# DNS failure or refused connection
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Connection failed",
}
except requests.exceptions.RequestException as e:
# Catch-all for any other request failure
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": str(e),
}
if __name__ == "__main__":
app()
What does this code do?
The check_endpoint function uses requests.get() to hit a URL with a configurable timeout (default 5 seconds). If the server responds, it returns the status code and the elapsed time in milliseconds.
The try/except blocks handle three failure scenarios: the server takes too long (Timeout), the connection cannot be established (ConnectionError), and any other unexpected request error (RequestException). Each returns a consistent dictionary so the rest of your code always knows what shape the result takes.
The if __name__ == "__main__" guard at the bottom ensures the Typer app only runs when you execute the file directly.
- Save monitor.py.
- In your terminal, verify the file has no syntax errors by running:
python monitor.py --help
You should see a short help message with no errors. The app has no commands yet, so the output will be minimal.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is still active. You should see (.venv) at the start of your terminal prompt. If not, reactivate it with .venv\Scripts\activate (Windows) or source .venv/bin/activate (macOS/Linux).
Help me fix a ModuleNotFoundError when running monitor.py
Write the load_services and save_services functions
Your CLI needs a way to read the list of services from the JSON configuration file and write new entries back to it.
- In monitor.py, place your cursor on the blank line between CONFIG_FILE = Path("services.json") and def check_endpoint. Add the load_services function:
def load_services() -> list[dict]:
"""Load services from the JSON configuration file."""
# Exit early if the config file does not exist
if not CONFIG_FILE.exists():
console.print("[red]No services.json found. Run 'add' to create one.[/red]")
raise typer.Exit(code=1)
with open(CONFIG_FILE, "r") as f:
data = json.load(f)
return data.get("services", [])
What does this code do?
The load_services function checks whether services.json exists before attempting to read it. If the file is missing, it prints a helpful red error message and exits the CLI with a non-zero code (signaling failure).
The .get("services", []) call safely extracts the services list from the JSON structure, defaulting to an empty list if the key is missing.
- Save monitor.py.
You will not see a change yet. The next function completes the config handling pair.
- Directly below load_services (and above def check_endpoint), add the save_services function:
def save_services(services: list[dict]) -> None:
"""Save services list to the JSON configuration file."""
with open(CONFIG_FILE, "w") as f:
json.dump({"services": services}, f, indent=2)
What does this code do?
The save_services function writes the services list back to services.json with indent=2 for human-readable formatting. This is the counterpart to load_services and will be used later when you implement the add command.
- Save monitor.py.
- Run python monitor.py --help again to confirm there are no syntax errors.
Seeing an IndentationError?
Python is strict about whitespace. Make sure each function body is indented with exactly 4 spaces, and that your new functions are at the same indentation level as check_endpoint (no indentation on the def line).
Help me fix an IndentationError in monitor.py
Test the functions against a live endpoint
Your functions are written but you have not tested them yet. Before wiring up the full CLI, you will populate services.json with test data and call check_endpoint directly.
- Open services.json in VS Code and replace its contents with this configuration:
{
"services": [
{
"name": "HTTPBin",
"type": "http",
"url": "https://httpbin.org/status/200",
"timeout": 5
},
{
"name": "JSON Placeholder",
"type": "http",
"url": "https://jsonplaceholder.typicode.com/posts/1",
"timeout": 5
}
]
}
What are these services?
HTTPBin is a free HTTP testing service that returns whatever status code you request. The /status/200 endpoint always returns a 200 OK response, making it perfect for verifying your health check works.
JSON Placeholder is another free testing API that returns sample JSON data. Both services are publicly available and reliable for testing.
- Save services.json.
- Test check_endpoint against a live URL by running this command:
python -c "from monitor import check_endpoint; print(check_endpoint('https://httpbin.org/status/200'))"
You should see a dictionary printed with 'status': 'UP', 'code': 200, a response_time_ms value, and 'error': None.
Seeing a connection error or timeout?
This test requires an active internet connection. If you see a connection error, check that you are online. If the response time feels slow, that is normal for a first request to an external service.
Help me debug a connection error when testing check_endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved both monitor.py and services.json.
ⓧ I'd like to double check the full code
Here is the complete monitor.py at this point:
import json
import time
from pathlib import Path
import requests
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(no_args_is_help=True)
console = Console()
CONFIG_FILE = Path("services.json")
def load_services() -> list[dict]:
"""Load services from the JSON configuration file."""
if not CONFIG_FILE.exists():
console.print("[red]No services.json found. Run 'add' to create one.[/red]")
raise typer.Exit(code=1)
with open(CONFIG_FILE, "r") as f:
data = json.load(f)
return data.get("services", [])
def save_services(services: list[dict]) -> None:
"""Save services list to the JSON configuration file."""
with open(CONFIG_FILE, "w") as f:
json.dump({"services": services}, f, indent=2)
def check_endpoint(url: str, timeout: int = 5) -> dict:
"""Check an HTTP endpoint and return status information."""
try:
response = requests.get(url, timeout=timeout)
return {
"status": "UP" if response.status_code < 400 else "DOWN",
"code": response.status_code,
"response_time_ms": round(response.elapsed.total_seconds() * 1000),
"error": None,
}
except requests.exceptions.Timeout:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Timeout",
}
except requests.exceptions.ConnectionError:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Connection failed",
}
except requests.exceptions.RequestException as e:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": str(e),
}
if __name__ == "__main__":
app()
And here is services.json:
{
"services": [
{
"name": "HTTPBin",
"type": "http",
"url": "https://httpbin.org/status/200",
"timeout": 5
},
{
"name": "JSON Placeholder",
"type": "http",
"url": "https://jsonplaceholder.typicode.com/posts/1",
"timeout": 5
}
]
}
Your health check logic is solid. Next up, you will wire these functions into a proper CLI with named subcommands so you can run health checks from the terminal like a real DevOps tool.
Create the CLI with Typer
You have working helper functions that can load services from a config file and check their HTTP health. Now it's time to wire them into a proper command-line interface so you can trigger health checks from your terminal.
Typer turns Python functions into CLI subcommands using decorators and type hints. You decorate a function with @app.command() and Typer handles argument parsing, help text generation, and error messages automatically.
In this step, get ready to:
- Create a check command that runs health checks on all configured services.
- Create an add command that registers new services in the config file.
- Create a list command that displays all configured services.
Implement the check command
The check command is the core of your CLI. It loads every service from the config, runs a health check on each one, and prints a summary of how many are healthy.
- In monitor.py, find the line that says if __name__ == "__main__": near the bottom of the file.
- Add the following code above that line:
@app.command()
def check():
"""Check the health of all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured. Use 'add' to add one.[/yellow]")
raise typer.Exit()
healthy = 0
total = len(services)
for service in services:
result = check_endpoint(service["url"], service.get("timeout", 5))
if result["status"] == "UP":
healthy += 1
console.print(f"{service['name']}: {result['status']}")
console.print(f"\nSummary: {healthy}/{total} services healthy")
What does this code do?
- @app.command() registers check as a subcommand. Typer uses the function name as the command name automatically.
- The docstring "Check the health of all configured services." becomes the command's help text when someone runs python monitor.py check --help.
- The loop calls check_endpoint() on each service and tracks how many return UP.
- At the end, a summary line shows the ratio of healthy services to total services.
- Save monitor.py.
- Test your new command by running:
python monitor.py check
You should see each service name followed by its status (UP or DOWN), then a summary line like Summary: 2/2 services healthy.
Seeing an error instead of results?
- Make sure the @app.command() decorator is directly above def check(): with no blank lines between them.
- Check that the entire check function is above if __name__ == "__main__": at the bottom of the file.
- Confirm your virtual environment is still active. You should see (.venv) in your terminal prompt.
Why isn't my check command working?
Implement the add command
Right now, the only way to add a service is by manually editing services.json. The add command gives you a quick way to register new endpoints directly from the terminal.
- In monitor.py, add the following code below the check function you just wrote (still above if __name__ == "__main__":):
@app.command()
def add(
name: str = typer.Argument(help="Name of the service"),
url: str = typer.Argument(help="URL to monitor"),
timeout: int = typer.Option(5, help="Request timeout in seconds"),
):
"""Add a new service to monitor."""
if CONFIG_FILE.exists():
services = load_services()
else:
services = []
new_service = {"name": name, "type": "http", "url": url, "timeout": timeout}
services.append(new_service)
save_services(services)
console.print(f"[green]Added '{name}' ({url}) to monitoring list.[/green]")
What does this code do?
- typer.Argument() declares positional arguments. The learner must provide name and url when running the command.
- typer.Option(5) declares an optional flag --timeout that defaults to 5 seconds if not specified.
- The function checks if services.json already exists. If it does, it loads the current list. If not, it starts fresh with an empty list.
- After appending the new entry, save_services() writes the updated list back to disk.
- Save monitor.py.
- Test the add command by running:
python monitor.py add "Google" "https://google.com"
You should see a green confirmation message: Added 'Google' (https://google.com) to monitoring list..
Seeing a missing argument error?
- Both name and url are required positional arguments. Make sure you pass them both in quotes after add.
- If your service name has spaces, wrap it in quotes like "My Service".
Why is my add command giving an error?
Implement the list command
The list command gives you a quick overview of what services are configured without running any health checks.
- In monitor.py, add the following code below the add function (still above if __name__ == "__main__":):
@app.command(name="list")
def list_services():
"""List all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured.[/yellow]")
raise typer.Exit()
for service in services:
console.print(f"{service['name']} - {service.get('url', '')}")
Why use name="list" in the decorator?
Python already has a built-in called list, so naming your function list would shadow it. The name="list" parameter in @app.command(name="list") tells Typer to register the command as list in the CLI while keeping the function name as list_services in your code.
- Save monitor.py.
- Test the list command by running:
python monitor.py list
You should see each configured service printed with its name and URL, including the Google entry you added in the previous substep.
- Verify that your entire CLI is wired up by running:
python monitor.py --help
You should see a usage line listing all three commands: check, add, and list.
Only seeing one or two commands in help?
- Each command function needs its own @app.command() decorator on the line directly above it.
- Make sure all three functions are above if __name__ == "__main__": at the bottom of the file.
- Check for indentation errors. Each function body must be indented one level inside its def statement.
Why are my commands not showing up in the help output?
Clean up your test service
You added a Google entry to test the add command. Open services.json and remove the Google entry so your config is back to just HTTPBin and JSON Placeholder for the next step.
✔️ Awesome, I've got everything!
Great. Double check you've saved monitor.py and confirmed all three commands appear in python monitor.py --help.
ⓧ I'd like to double check the full code
Here is the complete monitor.py at this point:
import json
import time
from pathlib import Path
import requests
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(no_args_is_help=True)
console = Console()
CONFIG_FILE = Path("services.json")
def load_services() -> list[dict]:
"""Load services from the JSON configuration file."""
if not CONFIG_FILE.exists():
console.print("[red]No services.json found. Run 'add' to create one.[/red]")
raise typer.Exit(code=1)
with open(CONFIG_FILE, "r") as f:
data = json.load(f)
return data.get("services", [])
def save_services(services: list[dict]) -> None:
"""Save services list to the JSON configuration file."""
with open(CONFIG_FILE, "w") as f:
json.dump({"services": services}, f, indent=2)
def check_endpoint(url: str, timeout: int = 5) -> dict:
"""Check an HTTP endpoint and return status information."""
try:
response = requests.get(url, timeout=timeout)
return {
"status": "UP" if response.status_code < 400 else "DOWN",
"code": response.status_code,
"response_time_ms": round(response.elapsed.total_seconds() * 1000),
"error": None,
}
except requests.exceptions.Timeout:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Timeout",
}
except requests.exceptions.ConnectionError:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Connection failed",
}
except requests.exceptions.RequestException as e:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": str(e),
}
@app.command()
def check():
"""Check the health of all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured. Use 'add' to add one.[/yellow]")
raise typer.Exit()
healthy = 0
total = len(services)
for service in services:
result = check_endpoint(service["url"], service.get("timeout", 5))
if result["status"] == "UP":
healthy += 1
console.print(f"{service['name']}: {result['status']}")
console.print(f"\nSummary: {healthy}/{total} services healthy")
@app.command()
def add(
name: str = typer.Argument(help="Name of the service"),
url: str = typer.Argument(help="URL to monitor"),
timeout: int = typer.Option(5, help="Request timeout in seconds"),
):
"""Add a new service to monitor."""
if CONFIG_FILE.exists():
services = load_services()
else:
services = []
new_service = {"name": name, "type": "http", "url": url, "timeout": timeout}
services.append(new_service)
save_services(services)
console.print(f"[green]Added '{name}' ({url}) to monitoring list.[/green]")
@app.command(name="list")
def list_services():
"""List all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured.[/yellow]")
raise typer.Exit()
for service in services:
console.print(f"{service['name']} - {service.get('url', '')}")
if __name__ == "__main__":
app()
Your CLI is fully wired up with three subcommands. Next, you'll replace the plain text output with a formatted Rich table that color-codes health results for easier reading.
Add Rich Table Output
Your CLI commands work, but the plain text output makes it hard to scan results at a glance.
In this step, you will replace the basic print statements with formatted Rich tables that color-code service health green or red. This makes your tool look and feel like production-grade DevOps software.
In this step, get ready to:
- Replace the check command output with a color-coded Rich Table.
- Update the list command with a formatted table.
Replace the check command output with a Rich Table
Rich's Table class builds formatted terminal tables with column headers, alignment, and color styles. You already have Table and Console imported from the previous step.
- In monitor.py, find the entire check() function (starting at @app.command() above def check():).
- Replace the entire function with this code:
@app.command()
def check():
"""Check the health of all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured. Use 'add' to add one.[/yellow]")
raise typer.Exit()
# Create a formatted table with column headers
table = Table(title="Service Health Check")
table.add_column("Service", style="cyan", no_wrap=True)
table.add_column("URL", style="dim")
table.add_column("Status", justify="center")
table.add_column("Code", justify="center")
table.add_column("Response Time", justify="right")
healthy = 0
total = len(services)
for service in services:
result = check_endpoint(service["url"], service.get("timeout", 5))
# Color-code the status: green for UP, red for DOWN
if result["status"] == "UP":
status_display = "[bold green]UP[/bold green]"
healthy += 1
else:
error_info = f" ({result['error']})" if result["error"] else ""
status_display = f"[bold red]DOWN{error_info}[/bold red]"
# Format code and response time, showing "-" when unavailable
code_display = str(result["code"]) if result["code"] else "-"
time_display = (
f"{result['response_time_ms']} ms"
if result["response_time_ms"]
else "-"
)
table.add_row(
service["name"],
service["url"],
status_display,
code_display,
time_display,
)
# Print the table and a summary line
console.print(table)
console.print(
f"\n[bold]Summary:[/bold] {healthy}/{total} services healthy"
)
What does this code do?
This function builds a Rich Table instead of printing raw text lines.
- Table(title="Service Health Check") creates a new table with a title banner. Each add_column() call defines a column with a name, color style, and alignment.
- The Rich markup [bold green]UP[/bold green] makes the status text bold and green. Down services get red text with the error reason appended.
- code_display and time_display show a dash when the value is None (which happens when a service is unreachable).
- console.print(table) renders the entire table at once, followed by a bold summary line showing the healthy/total count.
- Save monitor.py.
- Test the check command by running:
python monitor.py check
You should see a bordered table with cyan service names, a green UP status for each service, the HTTP status code, and the response time in milliseconds.
Seeing a syntax error or no table?
- Check that you replaced the entire old check() function. If any of the old console.print(f"{service['name']}: {result['status']}") lines remain, you will get duplicate output or errors.
- Make sure the f-string quotes match. The Rich markup uses double quotes inside the string (e.g. "[bold green]UP[/bold green]"), so the outer assignment must also use double quotes.
- Help me debug my Rich Table output.
Update the list command with a formatted table
The list command still uses plain print lines. Let's give it the same table treatment so all your output is consistent.
- In monitor.py, find the list_services() function at the bottom of the file (above if __name__ == "__main__":).
- Replace the entire function with this code:
@app.command(name="list")
def list_services():
"""List all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured.[/yellow]")
raise typer.Exit()
# Build a table showing all configured services
table = Table(title="Configured Services")
table.add_column("Name", style="cyan")
table.add_column("Type", style="magenta")
table.add_column("URL / Host", style="dim")
table.add_column("Timeout", justify="right")
for service in services:
table.add_row(
service["name"],
service.get("type", "http"),
service.get("url", f"{service.get('host', '')}:{service.get('port', '')}"),
f"{service.get('timeout', 5)}s",
)
console.print(table)
What does this code do?
- This table has four columns: Name (cyan), Type (magenta), URL / Host (dim), and Timeout (right-aligned).
- The service.get("type", "http") pattern provides a default value of "http" when the key is missing. This makes the table work even with older config entries that do not have a type field.
- The URL / Host column uses a fallback expression that will display host:port for TCP services later in the Secret Mission.
- Save monitor.py.
- Test the list command by running:
python monitor.py list
You should see a bordered table titled "Configured Services" with your two entries (HTTPBin and JSON Placeholder) displayed in neat columns.
Table not showing or seeing an error?
- Confirm you kept the @app.command(name="list") decorator directly above def list_services():. Without it, Typer will not register the command.
- Make sure services.json exists in the same folder as monitor.py. If it is missing, the command will print a red error message.
- Help me fix my list command output.
✔️ Awesome, I've got everything!
Great. Double check you have saved monitor.py and both commands produce formatted tables.
ⓧ I'd like to double check the full code
Here is the complete monitor.py file at this point:
import json
import time
from pathlib import Path
import requests
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(no_args_is_help=True)
console = Console()
CONFIG_FILE = Path("services.json")
def load_services() -> list[dict]:
"""Load services from the JSON configuration file."""
if not CONFIG_FILE.exists():
console.print("[red]No services.json found. Run 'add' to create one.[/red]")
raise typer.Exit(code=1)
with open(CONFIG_FILE, "r") as f:
data = json.load(f)
return data.get("services", [])
def save_services(services: list[dict]) -> None:
"""Save services list to the JSON configuration file."""
with open(CONFIG_FILE, "w") as f:
json.dump({"services": services}, f, indent=2)
def check_endpoint(url: str, timeout: int = 5) -> dict:
"""Check an HTTP endpoint and return status information."""
try:
response = requests.get(url, timeout=timeout)
return {
"status": "UP" if response.status_code < 400 else "DOWN",
"code": response.status_code,
"response_time_ms": round(response.elapsed.total_seconds() * 1000),
"error": None,
}
except requests.exceptions.Timeout:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Timeout",
}
except requests.exceptions.ConnectionError:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": "Connection failed",
}
except requests.exceptions.RequestException as e:
return {
"status": "DOWN",
"code": None,
"response_time_ms": None,
"error": str(e),
}
@app.command()
def check():
"""Check the health of all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured. Use 'add' to add one.[/yellow]")
raise typer.Exit()
table = Table(title="Service Health Check")
table.add_column("Service", style="cyan", no_wrap=True)
table.add_column("URL", style="dim")
table.add_column("Status", justify="center")
table.add_column("Code", justify="center")
table.add_column("Response Time", justify="right")
healthy = 0
total = len(services)
for service in services:
result = check_endpoint(service["url"], service.get("timeout", 5))
if result["status"] == "UP":
status_display = "[bold green]UP[/bold green]"
healthy += 1
else:
error_info = f" ({result['error']})" if result["error"] else ""
status_display = f"[bold red]DOWN{error_info}[/bold red]"
code_display = str(result["code"]) if result["code"] else "-"
time_display = (
f"{result['response_time_ms']} ms"
if result["response_time_ms"]
else "-"
)
table.add_row(
service["name"],
service["url"],
status_display,
code_display,
time_display,
)
console.print(table)
console.print(
f"\n[bold]Summary:[/bold] {healthy}/{total} services healthy"
)
@app.command()
def add(
name: str = typer.Argument(help="Name of the service"),
url: str = typer.Argument(help="URL to monitor"),
timeout: int = typer.Option(5, help="Request timeout in seconds"),
):
"""Add a new service to monitor."""
if CONFIG_FILE.exists():
services = load_services()
else:
services = []
new_service = {"name": name, "type": "http", "url": url, "timeout": timeout}
services.append(new_service)
save_services(services)
console.print(f"[green]Added '{name}' ({url}) to monitoring list.[/green]")
@app.command(name="list")
def list_services():
"""List all configured services."""
services = load_services()
if not services:
console.print("[yellow]No services configured.[/yellow]")
raise typer.Exit()
table = Table(title="Configured Services")
table.add_column("Name", style="cyan")
table.add_column("Type", style="magenta")
table.add_column("URL / Host", style="dim")
table.add_column("Timeout", justify="right")
for service in services:
table.add_row(
service["name"],
service.get("type", "http"),
service.get("url", f"{service.get('host', '')}:{service.get('port', '')}"),
f"{service.get('timeout', 5)}s",
)
console.print(table)
if __name__ == "__main__":
app()
Your health monitor now produces polished, scannable output that you could show to any team. Next up, you will take on a Secret Mission to extend the tool with TCP port checking for non-HTTP services.
Secret mission
Add TCP Port Checking
Your health monitor only speaks HTTP right now. But what about databases, caches, and message queues that don't serve web pages? Extend your tool with raw TCP port checking to monitor any networked service.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder healthcheck on your Desktop (contains monitor.py, services.json, and the .venv virtual environment).
- Installed pip packages (typer, requests) inside the virtual environment.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend your health monitor with more services.
- Your project folder stays on your Desktop.
- The virtual environment stays in the .venv folder inside your project.
- To use your CLI again later, activate the virtual environment first, then run your commands.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal session. Your files stay on disk so you can pick up where you left off.
- If your virtual environment is still active, deactivate it by running:
deactivate
- To resume later, navigate back to your project folder and reactivate the virtual environment.
Delete - I don't want to use this again
Remove all project files and the virtual environment. This frees up disk space and leaves no traces of the project.
- If your virtual environment is still active, deactivate it first by running:
deactivate
- Delete the entire project folder from your Desktop.
macOS
- Remove the project folder by running:
cd ~/Desktop
rm -rf healthcheck
Windows
- Remove the project folder by running:
cd ~\Desktop
rmdir /s /q healthcheck
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see healthcheck in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional CLI tool that monitors service health from your terminal with color-coded output and structured subcommands.
You've learned how to:
- Set up a Python virtual environment with pip package management, and structure a Python project with separate config and application files.
- Used the requests library for HTTP health checks with proper timeout handling and exception-based error reporting.
- Built a multi-command CLI with Typer using Python type hints and formatted terminal output with Rich tables and color-coded styles.
- Secret Mission: Extended the tool with raw TCP port checking using Python's socket module to monitor non-HTTP services like databases and caches.
Ready to quiz yourself?