Build a Server Management Dashboard
Build a Flask web dashboard to monitor and manage your Ubuntu Server.
Introduction
30 Second Summary
Managing a server usually means opening a terminal and typing commands one by one. Checking CPU usage, restarting a crashed service, and reading through error logs each require their own set of memorized commands.
In this project, you will build a browser-based server management dashboard using Python and Flask. You'll access it from your Windows machine to monitor stats, control services, and read logs from a single interface.
What You'll Build
A custom server management dashboard built with Flask that exposes REST API endpoints and serves a tabbed HTML frontend for system monitoring, service control, and log viewing.
By the end of this project, you'll have:
- A Flask backend with REST API endpoints that report real-time CPU, memory, disk, and network stats from your Ubuntu Server.
- A service management panel that lists systemd services and lets you start, stop, and restart them through the browser.
- A log viewer that queries journalctl and displays color-coded system logs filtered by service.
- Secret Mission: add auto-refreshing stats that update every 3 seconds without a page reload.
Are there any prerequisites?
You need an Ubuntu Server running on your local machine and basic comfort with the Linux terminal. No Python web development experience is required.
Set Up the Project
Before you can build a server management dashboard, your Ubuntu Server needs the right Python tools installed and a clean project structure in place.
In this step, you will install Flask and psutil, create a virtual environment, set up your project files, and confirm that your server can send a web response to your Windows browser.
In this step, get ready to:
- Install Python packages and create a virtual environment.
- Set up the project directory and file structure.
- Confirm Flask responds to requests from your Windows browser.
Install Python tooling and create the project
Your Ubuntu Server comes with Python 3 pre-installed, but you need two additional system packages to manage dependencies and isolate your project.
- Update your package list and install the required system packages by running this command:
sudo apt update && sudo apt install -y python3-pip python3-venv
What does this command do?
`sudo apt update` refreshes the list of available packages so your server knows about the latest versions.
`python3-pip` gives you the `pip` package manager for installing Python libraries. `python3-venv` lets you create isolated virtual environments so your project's dependencies stay separate from the system Python.
- You should see output ending with a line like Processing triggers or a confirmation that packages are already at the newest version.
Seeing a permission denied error?
Make sure you typed sudo at the start of the command. Without it, your user account does not have permission to install system packages.
If you see a network error, check that your server has internet access by running ping google.com and confirming you get a response.
Help me fix my apt install error
- Create the project directory and set up a Python virtual environment by running these commands:
mkdir ~/server-dashboard && cd ~/server-dashboard
python3 -m venv venv
source venv/bin/activate
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python, preventing version conflicts between different projects.
The source venv/bin/activate command activates the environment for your current terminal session.
- Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran all three commands in sequence. If you closed your terminal or opened a new session, you need to cd ~/server-dashboard first, then run source venv/bin/activate again.
Help me activate my virtual environment
- Install Flask and psutil inside the virtual environment by running:
pip install flask==3.1.3 psutil==7.2.2
What are these packages?
Flask is a lightweight Python web framework that lets you create web servers and REST APIs with minimal code.
psutil (process and system utilities) is a library that reads live system information like CPU usage, memory consumption, and disk stats directly from the operating system.
- You should see Successfully installed messages for both packages in the output.
Install failing with a network error?
Check that your virtual environment is active (you should see (venv) in your prompt). If you accidentally ran pip install without activating the venv, run source venv/bin/activate first, then try again.
Help me fix my pip install error
- Create the project's directory structure and empty files by running these commands:
mkdir templates static
touch app.py templates/index.html static/style.css static/app.js
What is this file structure for?
Flask expects a templates folder for HTML files and a static folder for CSS and JavaScript. The app.py file is your main Flask application.
- Confirm the files exist by running:
ls
- You should see app.py, static, templates, and venv in the output.
Verify the setup
Before moving forward, confirm that both Python packages imported correctly and find your server's IP address for browser access.
- Verify Flask and psutil are installed correctly by running:
python3 -c "import flask; import psutil; print(flask.__version__, psutil.__version__)"
- You should see 3.1.3 7.2.2 printed in the terminal.
- Find your server's IP address by running:
hostname -I
- This prints one or more IP addresses. Note the first one. You will use it to access the dashboard from your Windows browser.
Keep your IP address handy
Write down or copy the IP address from hostname -I. You will type it into your Windows browser as http:// followed by the IP and :5000 throughout this project.
See a different version or an import error?
If you see an ImportError, make sure your virtual environment is active ((venv) in your prompt). The packages were installed inside the venv, so they are only available when it is activated.
Help me fix my import error
Test Flask works
The final check is to confirm your server can actually send a web page to your Windows machine. You will write a minimal Flask app, start the server, and visit it from your browser.
- Open app.py in the nano text editor by running:
nano app.py
- Type the following code into the editor:
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return 'Server dashboard coming soon'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
What does this code do?
`app = Flask(__name__)` creates a new Flask web application.
`@app.route('/')` tells Flask to respond when someone visits the root URL. The host='0.0.0.0' setting makes the server listen on all network interfaces, so your Windows machine can reach it.
- Save and exit nano by pressing Ctrl+X, then Y to confirm saving, then Enter to accept the filename.
- Start the Flask server by running:
sudo venv/bin/python app.py
Why use sudo here?
Later in this project, your app will manage system services using systemctl. Those commands require root privileges. Running the app with sudo from the start ensures everything works when you add service management.
For a production application, you would use proper authentication and privilege separation instead of running the entire app as root.
- You should see output that includes Running on http://0.0.0.0:5000 in the terminal.
- On your Windows machine, open a web browser and visit:
http://[[SERVER_IP="your server's IP address"]]:5000
- You should see the text Server dashboard coming soon displayed in your browser.
- Back in your Ubuntu terminal, press Ctrl+C to stop the Flask server.
Browser shows connection refused?
Make sure Flask is still running in the terminal (you should see the Running on message). If you accidentally closed it, run sudo venv/bin/python app.py again.
Double-check that you are using your server's IP address from hostname -I, not localhost or 127.0.0.1. Those only work on the server itself, not from your Windows machine.
If your firewall is blocking port 5000, run sudo ufw allow 5000 to open it.
Help me connect to my Flask server from Windows
Your development environment is ready and Flask is confirmed working across the network. Next up, you will build the system stats API and dashboard interface.
Build the System Stats API and Dashboard
Your server has all its tools installed and Flask is confirmed working. Now it is time to build the actual dashboard.
The core of any monitoring dashboard is data. In this step, you will create a REST API endpoint that reads live system stats from your server, then build a frontend that fetches and displays those stats in real time.
In this step, get ready to:
- Create a REST API endpoint that returns CPU, memory, disk, and network stats as JSON.
- Build an HTML dashboard with a tabbed layout and stat cards.
- Write JavaScript to fetch the stats and display them in the browser.
Create the stats API endpoint
The psutil library gives you access to system-level information like CPU usage, memory consumption, and disk space. You will wrap these readings in a Flask route that returns JSON, so any browser can request and display them.
- Open app.py in your editor by running:
nano ~/server-dashboard/app.py
- Replace any existing content with the following code:
import subprocess
import json
import time
from flask import Flask, render_template, jsonify, request
import psutil
app = Flask(__name__)
# Prime the CPU measurement so future calls return meaningful values
psutil.cpu_percent()
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/stats')
def get_stats():
# Non-blocking call returns CPU usage since last call
cpu_percent = psutil.cpu_percent(interval=None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
net = psutil.net_io_counters()
return jsonify({
'cpu': {
'percent': cpu_percent,
'count': psutil.cpu_count()
},
'memory': {
'total': memory.total,
'used': memory.used,
'percent': memory.percent
},
'disk': {
'total': disk.total,
'used': disk.used,
'percent': disk.percent
},
'network': {
'bytes_sent': net.bytes_sent,
'bytes_recv': net.bytes_recv
}
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
What does this code do?
The psutil.cpu_percent() call at the top primes the CPU measurement. Without it, the first API call would always return 0% because psutil needs a baseline to compare against.
The /api/stats route uses psutil.cpu_percent(interval=None) which returns instantly (non-blocking) based on the time elapsed since the last call. The other psutil functions read memory, disk, and network counters in a single snapshot.
- Save the file by pressing Ctrl+O, then Enter, then exit nano with Ctrl+X.
- Test the API by running the server and checking the endpoint:
cd ~/server-dashboard && sudo venv/bin/python app.py
- On your Windows machine, open a browser and visit http://your server IP address:5000/api/stats.
- You should see a JSON response with cpu, memory, disk, and network data.
- Press Ctrl+C in your terminal to stop the server for now.
Not seeing JSON in the browser?
- Make sure you included the full path to the URL including /api/stats at the end.
- If you see a connection error, confirm the server is still running in your terminal and that you are using the correct IP from hostname -I.
- Check for typos in the import lines. Python will show an ImportError if a module name is misspelled.
Why is my /api/stats endpoint not returning data?
✔️ Awesome, I've got everything!
Great. Make sure you have stopped the server with Ctrl+C before moving on.
ⓧ I'd like to double check the full code
import subprocess
import json
import time
from flask import Flask, render_template, jsonify, request
import psutil
app = Flask(__name__)
psutil.cpu_percent()
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/stats')
def get_stats():
cpu_percent = psutil.cpu_percent(interval=None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
net = psutil.net_io_counters()
return jsonify({
'cpu': {
'percent': cpu_percent,
'count': psutil.cpu_count()
},
'memory': {
'total': memory.total,
'used': memory.used,
'percent': memory.percent
},
'disk': {
'total': disk.total,
'used': disk.used,
'percent': disk.percent
},
'network': {
'bytes_sent': net.bytes_sent,
'bytes_recv': net.bytes_recv
}
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
Build the dashboard HTML and CSS
Your API returns raw data, but nobody wants to read JSON all day. You need a proper dashboard interface with a tabbed layout that organizes stats, services, and logs into separate views.
- Open templates/index.html by running:
nano ~/server-dashboard/templates/index.html
- Paste the following HTML into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Server Dashboard</h1>
<span id="live-badge" class="live-badge">Live</span>
</header>
<!-- Navigation tabs for switching between dashboard sections -->
<nav>
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="services">Services</button>
<button class="tab-btn" data-tab="logs">Logs</button>
</nav>
<main>
<!-- Overview tab: four stat cards showing system metrics -->
<section id="overview" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<h3>CPU</h3>
<p class="stat-value" id="cpu-percent">--</p>
<p class="stat-label" id="cpu-cores">-- cores</p>
</div>
<div class="stat-card">
<h3>Memory</h3>
<p class="stat-value" id="mem-percent">--</p>
<p class="stat-label" id="mem-detail">-- / --</p>
</div>
<div class="stat-card">
<h3>Disk</h3>
<p class="stat-value" id="disk-percent">--</p>
<p class="stat-label" id="disk-detail">-- / --</p>
</div>
<div class="stat-card">
<h3>Network</h3>
<p class="stat-value" id="net-sent">--</p>
<p class="stat-label" id="net-recv">Recv: --</p>
</div>
</div>
</section>
<!-- Services tab: table listing systemd services -->
<section id="services" class="tab-content">
<div class="section-header">
<h2>System Services</h2>
<button id="refresh-services" class="btn">Refresh</button>
</div>
<table id="services-table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Logs tab: filtered journal log viewer -->
<section id="logs" class="tab-content">
<div class="section-header">
<h2>System Logs</h2>
<div class="log-controls">
<select id="log-unit">
<option value="">All units</option>
</select>
<input type="number" id="log-lines" value="50" min="10" max="500">
<button id="fetch-logs" class="btn">Fetch Logs</button>
</div>
</div>
<div id="log-container" class="log-container"></div>
</section>
</main>
<!-- Toast notification for success/error messages -->
<div id="toast" class="toast"></div>
<script src="/static/app.js"></script>
</body>
</html>
What does this HTML do?
- The nav buttons use data-tab attributes to switch between three sections: Overview, Services, and Logs.
- Each stat card has an id (like cpu-percent and mem-detail) so JavaScript can inject live values into them.
- The Services and Logs sections have empty containers that will be populated by JavaScript in later steps.
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
Now add the dark-theme styles that give the dashboard its appearance.
- Open static/style.css by running:
nano ~/server-dashboard/static/style.css
- Paste the following CSS into the file:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
header {
background: #16213e;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #0f3460;
}
header h1 {
font-size: 1.4rem;
color: #ffffff;
}
.live-badge {
background: #00c853;
color: #000;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: bold;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
nav {
background: #16213e;
padding: 0 2rem;
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #0f3460;
}
.tab-btn {
background: none;
border: none;
color: #a0a0a0;
padding: 0.75rem 1.5rem;
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-btn:hover {
color: #ffffff;
}
.tab-btn.active {
color: #4fc3f7;
border-bottom-color: #4fc3f7;
}
main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.stat-card h3 {
color: #a0a0a0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.75rem;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #4fc3f7;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.85rem;
color: #808080;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
font-size: 1.2rem;
}
.btn {
background: #0f3460;
color: #4fc3f7;
border: 1px solid #4fc3f7;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.btn:hover {
background: #4fc3f7;
color: #000;
}
.btn-danger {
border-color: #ef5350;
color: #ef5350;
}
.btn-danger:hover {
background: #ef5350;
color: #fff;
}
.btn-success {
border-color: #66bb6a;
color: #66bb6a;
}
.btn-success:hover {
background: #66bb6a;
color: #000;
}
table {
width: 100%;
border-collapse: collapse;
background: #16213e;
border-radius: 8px;
overflow: hidden;
}
thead {
background: #0f3460;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
}
th {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #a0a0a0;
}
tr {
border-bottom: 1px solid #0f3460;
}
.status-active {
color: #66bb6a;
}
.status-inactive {
color: #ef5350;
}
.status-failed {
color: #ef5350;
font-weight: bold;
}
.log-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.log-controls select,
.log-controls input {
background: #0f3460;
border: 1px solid #1a3a6b;
color: #e0e0e0;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.log-controls input {
width: 80px;
}
.log-container {
background: #0d1117;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem;
max-height: 500px;
overflow-y: auto;
font-family: "Cascadia Code", "Fira Code", monospace;
font-size: 0.8rem;
line-height: 1.6;
}
.log-entry {
padding: 0.25rem 0;
border-bottom: 1px solid #161b22;
}
.log-entry .log-time {
color: #808080;
margin-right: 0.5rem;
}
.log-entry .log-unit {
color: #4fc3f7;
margin-right: 0.5rem;
}
.log-entry.priority-3,
.log-entry.priority-2,
.log-entry.priority-1,
.log-entry.priority-0 {
color: #ef5350;
}
.log-entry.priority-4 {
color: #ffb74d;
}
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #16213e;
border: 1px solid #4fc3f7;
padding: 1rem 1.5rem;
border-radius: 8px;
display: none;
z-index: 1000;
}
.toast.show {
display: block;
}
.toast.error {
border-color: #ef5350;
color: #ef5350;
}
What does this stylesheet do?
- The dark background (#1a1a2e) with light text creates a professional monitoring-tool feel.
- The .live-badge uses a CSS pulse animation so it gently blinks to show the dashboard is active.
- The .stats-grid uses CSS Grid to automatically arrange the stat cards into a responsive row.
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
You won't see a visual change yet because the JavaScript has not been added to load the data.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to the JavaScript.
ⓧ I'd like to double check the full HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>Server Dashboard</h1>
<span id="live-badge" class="live-badge">Live</span>
</header>
<nav>
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="services">Services</button>
<button class="tab-btn" data-tab="logs">Logs</button>
</nav>
<main>
<section id="overview" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<h3>CPU</h3>
<p class="stat-value" id="cpu-percent">--</p>
<p class="stat-label" id="cpu-cores">-- cores</p>
</div>
<div class="stat-card">
<h3>Memory</h3>
<p class="stat-value" id="mem-percent">--</p>
<p class="stat-label" id="mem-detail">-- / --</p>
</div>
<div class="stat-card">
<h3>Disk</h3>
<p class="stat-value" id="disk-percent">--</p>
<p class="stat-label" id="disk-detail">-- / --</p>
</div>
<div class="stat-card">
<h3>Network</h3>
<p class="stat-value" id="net-sent">--</p>
<p class="stat-label" id="net-recv">Recv: --</p>
</div>
</div>
</section>
<section id="services" class="tab-content">
<div class="section-header">
<h2>System Services</h2>
<button id="refresh-services" class="btn">Refresh</button>
</div>
<table id="services-table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<section id="logs" class="tab-content">
<div class="section-header">
<h2>System Logs</h2>
<div class="log-controls">
<select id="log-unit">
<option value="">All units</option>
</select>
<input type="number" id="log-lines" value="50" min="10" max="500">
<button id="fetch-logs" class="btn">Fetch Logs</button>
</div>
</div>
<div id="log-container" class="log-container"></div>
</section>
</main>
<div id="toast" class="toast"></div>
<script src="/static/app.js"></script>
</body>
</html>
Add JavaScript to fetch and display stats
The HTML has placeholder dashes in each stat card. You need JavaScript to call your /api/stats endpoint, parse the JSON, and write the values into those elements.
- Open static/app.js by running:
nano ~/server-dashboard/static/app.js
- Paste the following JavaScript into the file:
let refreshInterval = null;
document.addEventListener('DOMContentLoaded', function() {
setupTabs();
loadStats();
startAutoRefresh();
});
// Handle tab switching between Overview, Services, and Logs
function setupTabs() {
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.querySelectorAll('.tab-content').forEach(function(tab) {
tab.classList.remove('active');
});
document.getElementById(btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'services') {
loadServices();
} else if (btn.dataset.tab === 'logs') {
populateUnitDropdown();
}
});
});
document.getElementById('refresh-services').addEventListener('click', loadServices);
document.getElementById('fetch-logs').addEventListener('click', loadLogs);
}
// Poll the stats endpoint every 3 seconds
function startAutoRefresh() {
refreshInterval = setInterval(loadStats, 3000);
}
// Fetch system stats from the API and update the dashboard cards
function loadStats() {
fetch('/api/stats')
.then(function(response) { return response.json(); })
.then(function(data) {
document.getElementById('cpu-percent').textContent = data.cpu.percent + '%';
document.getElementById('cpu-cores').textContent = data.cpu.count + ' cores';
document.getElementById('mem-percent').textContent = data.memory.percent + '%';
document.getElementById('mem-detail').textContent =
formatBytes(data.memory.used) + ' / ' + formatBytes(data.memory.total);
document.getElementById('disk-percent').textContent = data.disk.percent + '%';
document.getElementById('disk-detail').textContent =
formatBytes(data.disk.used) + ' / ' + formatBytes(data.disk.total);
document.getElementById('net-sent').textContent =
'Sent: ' + formatBytes(data.network.bytes_sent);
document.getElementById('net-recv').textContent =
'Recv: ' + formatBytes(data.network.bytes_recv);
});
}
// Convert raw byte values into human-readable strings
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
}
// Safely escape HTML to prevent injection in log messages
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// Show a temporary notification at the bottom-right of the screen
function showToast(message, type) {
var toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (type ? ' ' + type : '');
setTimeout(function() {
toast.className = 'toast';
}, 3000);
}
What does this JavaScript do?
- When the page loads, loadStats() calls your /api/stats endpoint and fills the stat cards with live data.
- The startAutoRefresh() function sets up a 3-second polling interval so the stats update continuously without a page reload.
- The formatBytes() helper converts raw byte values (like 8345612288) into readable strings (like 7.8 GB).
- The setupTabs() function wires up tab switching and also attaches click handlers for the services and logs buttons (which you will build in later steps).
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
- Start the Flask server by running:
cd ~/server-dashboard && sudo venv/bin/python app.py
- On your Windows machine, open a browser and visit http://your server IP address:5000.
- You should see a dark-themed dashboard with four stat cards showing your server's live CPU percentage, memory usage, disk usage, and network bytes.
Seeing a blank page or dashes that never update?
- Open your browser's developer console (F12 on Windows) and look for JavaScript errors. A typo in app.js will prevent the fetch from running.
- If you see a 404 for /static/app.js or /static/style.css, double-check that you saved the files in the correct directories (static/ and templates/).
- Make sure you are running the server from the ~/server-dashboard directory so Flask can find the templates and static folders.
Help me debug why my dashboard is not showing stats.
✔️ Awesome, I've got everything!
Great. Your dashboard is live and showing real server stats. Keep the server running for the next step.
ⓧ I'd like to double check the full JavaScript
let refreshInterval = null;
document.addEventListener('DOMContentLoaded', function() {
setupTabs();
loadStats();
startAutoRefresh();
});
function setupTabs() {
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.querySelectorAll('.tab-content').forEach(function(tab) {
tab.classList.remove('active');
});
document.getElementById(btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'services') {
loadServices();
} else if (btn.dataset.tab === 'logs') {
populateUnitDropdown();
}
});
});
document.getElementById('refresh-services').addEventListener('click', loadServices);
document.getElementById('fetch-logs').addEventListener('click', loadLogs);
}
function startAutoRefresh() {
refreshInterval = setInterval(loadStats, 3000);
}
function loadStats() {
fetch('/api/stats')
.then(function(response) { return response.json(); })
.then(function(data) {
document.getElementById('cpu-percent').textContent = data.cpu.percent + '%';
document.getElementById('cpu-cores').textContent = data.cpu.count + ' cores';
document.getElementById('mem-percent').textContent = data.memory.percent + '%';
document.getElementById('mem-detail').textContent =
formatBytes(data.memory.used) + ' / ' + formatBytes(data.memory.total);
document.getElementById('disk-percent').textContent = data.disk.percent + '%';
document.getElementById('disk-detail').textContent =
formatBytes(data.disk.used) + ' / ' + formatBytes(data.disk.total);
document.getElementById('net-sent').textContent =
'Sent: ' + formatBytes(data.network.bytes_sent);
document.getElementById('net-recv').textContent =
'Recv: ' + formatBytes(data.network.bytes_recv);
});
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function showToast(message, type) {
var toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (type ? ' ' + type : '');
setTimeout(function() {
toast.className = 'toast';
}, 3000);
}
Your dashboard is reading and displaying real system data. Next up, you will add service management so you can start, stop, and restart systemd services directly from the browser.
Add Service Management
Your dashboard already shows live system stats. Now you will give it the power to actually control your server by managing systemd services through the browser.
Every background process on your Ubuntu Server (SSH, networking, logging) runs as a systemd service. Normally you manage these by typing systemctl commands in the terminal. In this step, you will build API endpoints that run those same commands for you and wire them up to clickable buttons in the dashboard.
In this step, get ready to:
- Create API endpoints that list services and accept start/stop/restart commands.
- Build JavaScript that displays services in a table with action buttons.
- Add input validation and user-facing success/error messages.
Create the services API endpoints
Your dashboard needs two new endpoints: one to list all services, and another to start, stop, or restart a specific service. Both use Python's subprocess module to run systemctl commands behind the scenes.
- In app.py, find the closing brace of the get_stats() function (the final return jsonify({...}) block).
- Add the following GET endpoint below it to list all systemd services:
@app.route('/api/services')
def get_services():
# Run systemctl to get all service units
result = subprocess.run(
['systemctl', 'list-units', '--type=service', '--all',
'--no-pager', '--no-legend'],
capture_output=True, text=True
)
# Parse each line into a structured dictionary
services = []
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
parts = line.split()
if len(parts) >= 4:
services.append({
'name': parts[0],
'load': parts[1],
'active': parts[2],
'sub': parts[3],
'description': ' '.join(parts[4:])
})
return jsonify(services)
What does this code do?
subprocess.run() executes the systemctl list-units command exactly as you would type it in the terminal. The flags --no-pager and --no-legend strip decorative output so you get clean, parseable lines.
Each line of output contains columns separated by whitespace: UNIT, LOAD, ACTIVE, SUB, and DESCRIPTION. The code splits each line and maps those columns into a JSON-friendly dictionary.
- Directly below the get_services() function, add the POST endpoint that controls individual services:
@app.route('/api/services/<service_name>/<action>', methods=['POST'])
def manage_service(service_name, action):
# Only allow safe actions
if action not in ['start', 'stop', 'restart']:
return jsonify({'error': 'Invalid action'}), 400
# Ensure the service name has the .service suffix
if not service_name.endswith('.service'):
service_name += '.service'
# Run the systemctl command
result = subprocess.run(
['systemctl', action, service_name],
capture_output=True, text=True
)
if result.returncode == 0:
return jsonify({'status': 'success',
'message': f'Service {action}ed successfully'})
else:
return jsonify({'status': 'error',
'message': result.stderr}), 500
What does this code do?
This endpoint takes the service name and action directly from the URL path. It validates that the action is one of start, stop, or restart before running anything. This prevents someone from injecting arbitrary systemctl subcommands.
The .service suffix check ensures the name resolves to a valid systemd service unit. If the command succeeds (return code 0), the endpoint returns a success message. Otherwise it returns the error text from stderr.
- Save app.py.
- Because you started Flask with debug=True, it automatically reloads when it detects file changes. You should see a "Restarting with stat" message in your terminal.
Don't see the auto-reload message?
If Flask did not restart automatically, press Ctrl+C in the terminal running your app, then start it again with:
sudo venv/bin/python app.py
Make sure you saved the file and that there are no Python syntax errors. Check for missing colons or unmatched parentheses.
Help me debug why my Flask app won't restart after editing app.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import subprocess
import json
import time
from flask import Flask, render_template, jsonify, request
import psutil
app = Flask(__name__)
psutil.cpu_percent()
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/stats')
def get_stats():
cpu_percent = psutil.cpu_percent(interval=None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
net = psutil.net_io_counters()
return jsonify({
'cpu': {
'percent': cpu_percent,
'count': psutil.cpu_count()
},
'memory': {
'total': memory.total,
'used': memory.used,
'percent': memory.percent
},
'disk': {
'total': disk.total,
'used': disk.used,
'percent': disk.percent
},
'network': {
'bytes_sent': net.bytes_sent,
'bytes_recv': net.bytes_recv
}
})
@app.route('/api/services')
def get_services():
result = subprocess.run(
['systemctl', 'list-units', '--type=service', '--all',
'--no-pager', '--no-legend'],
capture_output=True, text=True
)
services = []
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
parts = line.split()
if len(parts) >= 4:
services.append({
'name': parts[0],
'load': parts[1],
'active': parts[2],
'sub': parts[3],
'description': ' '.join(parts[4:])
})
return jsonify(services)
@app.route('/api/services/<service_name>/<action>', methods=['POST'])
def manage_service(service_name, action):
if action not in ['start', 'stop', 'restart']:
return jsonify({'error': 'Invalid action'}), 400
if not service_name.endswith('.service'):
service_name += '.service'
result = subprocess.run(
['systemctl', action, service_name],
capture_output=True, text=True
)
if result.returncode == 0:
return jsonify({'status': 'success',
'message': f'Service {action}ed successfully'})
else:
return jsonify({'status': 'error',
'message': result.stderr}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
Build the services UI
The backend endpoints are ready. Now you need JavaScript functions that fetch service data and render it in the table that already exists in your HTML.
- In static/app.js, find the loadStats() function. Add the following loadServices() function directly below it:
function loadServices() {
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var tbody = document.querySelector('#services-table tbody');
tbody.innerHTML = '';
// Build a table row for each service
services.forEach(function(svc) {
var statusClass = 'status-' + svc.active;
var row = document.createElement('tr');
row.innerHTML =
'<td>' + svc.name + '</td>' +
'<td class="' + statusClass + '">' + svc.active + ' (' + svc.sub + ')</td>' +
'<td>' +
'<button class="btn btn-success" onclick="manageService(\'' + svc.name + '\', \'start\')">Start</button> ' +
'<button class="btn btn-danger" onclick="manageService(\'' + svc.name + '\', \'stop\')">Stop</button> ' +
'<button class="btn" onclick="manageService(\'' + svc.name + '\', \'restart\')">Restart</button>' +
'</td>';
tbody.appendChild(row);
});
});
}
What does this code do?
This function fetches the list of services from your new API endpoint. It clears the table body, then creates a new row for each service with the service name, a color-coded status, and three action buttons.
The statusClass variable maps the service's active state (like "active" or "inactive") to a CSS class that sets the text color. Green for active, red for inactive or failed.
- Directly below loadServices(), add the manageService() and populateUnitDropdown() functions:
function manageService(name, action) {
// Send the action to the backend
fetch('/api/services/' + name + '/' + action, { method: 'POST' })
.then(function(response) { return response.json(); })
.then(function(data) {
showToast(data.message, data.status === 'error' ? 'error' : '');
// Refresh the services table to show updated status
loadServices();
});
}
function populateUnitDropdown() {
// Fetch services to populate the log filter dropdown
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var select = document.getElementById('log-unit');
select.innerHTML = '<option value="">All units</option>';
services.forEach(function(svc) {
if (svc.active === 'active') {
var option = document.createElement('option');
option.value = svc.name;
option.textContent = svc.name;
select.appendChild(option);
}
});
});
}
What does this code do?
manageService() sends a POST request to your backend with the service name and desired action. When the response arrives, it shows a toast notification (success or error) and refreshes the table so you immediately see the updated status.
populateUnitDropdown() reuses the services endpoint to fill the Logs tab's filter dropdown. It only includes services that are currently active, since those are the ones producing log entries.
- Save static/app.js.
- Switch to your Windows browser and refresh the dashboard page.
- Click the Services tab. You should see a table populated with your server's systemd services, each with a colored status label and three action buttons.
Services tab is empty?
Open your browser's developer console (press F12, then click the Console tab) and look for error messages.
- A 404 error on /api/services means Flask did not pick up your new endpoint. Restart the server with sudo venv/bin/python app.py.
- A 500 error means there is a Python syntax error in app.py. Check the terminal running Flask for the traceback.
Help me debug why my Services tab shows no data.
✔️ Awesome, I've got everything!
Great. Make sure you saved your file before continuing.
ⓧ I'd like to double check the full code
let refreshInterval = null;
document.addEventListener('DOMContentLoaded', function() {
setupTabs();
loadStats();
startAutoRefresh();
});
function setupTabs() {
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.querySelectorAll('.tab-content').forEach(function(tab) {
tab.classList.remove('active');
});
document.getElementById(btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'services') {
loadServices();
} else if (btn.dataset.tab === 'logs') {
populateUnitDropdown();
}
});
});
document.getElementById('refresh-services').addEventListener('click', loadServices);
document.getElementById('fetch-logs').addEventListener('click', loadLogs);
}
function startAutoRefresh() {
refreshInterval = setInterval(loadStats, 3000);
}
function loadStats() {
fetch('/api/stats')
.then(function(response) { return response.json(); })
.then(function(data) {
document.getElementById('cpu-percent').textContent = data.cpu.percent + '%';
document.getElementById('cpu-cores').textContent = data.cpu.count + ' cores';
document.getElementById('mem-percent').textContent = data.memory.percent + '%';
document.getElementById('mem-detail').textContent =
formatBytes(data.memory.used) + ' / ' + formatBytes(data.memory.total);
document.getElementById('disk-percent').textContent = data.disk.percent + '%';
document.getElementById('disk-detail').textContent =
formatBytes(data.disk.used) + ' / ' + formatBytes(data.disk.total);
document.getElementById('net-sent').textContent =
'Sent: ' + formatBytes(data.network.bytes_sent);
document.getElementById('net-recv').textContent =
'Recv: ' + formatBytes(data.network.bytes_recv);
});
}
function loadServices() {
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var tbody = document.querySelector('#services-table tbody');
tbody.innerHTML = '';
services.forEach(function(svc) {
var statusClass = 'status-' + svc.active;
var row = document.createElement('tr');
row.innerHTML =
'<td>' + svc.name + '</td>' +
'<td class="' + statusClass + '">' + svc.active + ' (' + svc.sub + ')</td>' +
'<td>' +
'<button class="btn btn-success" onclick="manageService(\'' + svc.name + '\', \'start\')">Start</button> ' +
'<button class="btn btn-danger" onclick="manageService(\'' + svc.name + '\', \'stop\')">Stop</button> ' +
'<button class="btn" onclick="manageService(\'' + svc.name + '\', \'restart\')">Restart</button>' +
'</td>';
tbody.appendChild(row);
});
});
}
function manageService(name, action) {
fetch('/api/services/' + name + '/' + action, { method: 'POST' })
.then(function(response) { return response.json(); })
.then(function(data) {
showToast(data.message, data.status === 'error' ? 'error' : '');
loadServices();
});
}
function populateUnitDropdown() {
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var select = document.getElementById('log-unit');
select.innerHTML = '<option value="">All units</option>';
services.forEach(function(svc) {
if (svc.active === 'active') {
var option = document.createElement('option');
option.value = svc.name;
option.textContent = svc.name;
select.appendChild(option);
}
});
});
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function showToast(message, type) {
var toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (type ? ' ' + type : '');
setTimeout(function() {
toast.className = 'toast';
}, 3000);
}
Add input validation and error handling
The validation logic is already in your POST endpoint. Now you will test the full flow to confirm it works end-to-end.
- In the Services tab, find a service with a green "active" status (for example cron.service or ssh.service).
- Click the Stop button next to that service.
- You should see a blue toast notification in the bottom-right corner saying the service was stopped successfully. The service's status in the table should turn red and show "inactive".
- Click the Start button on that same service to bring it back up.
- The toast should confirm success and the status should return to green "active".
Why does validation matter here?
Your endpoint runs shell commands with root privileges. The validation guards in your code prevent unexpected input from reaching systemctl. Only start, stop, and restart are allowed, and the service name must end with .service. For a production app, you would add authentication and a more restrictive allowlist of services.
Seeing a red error toast?
- If the error mentions "permission denied" or "access denied", your Flask app is not running with sudo. Stop it and restart with sudo venv/bin/python app.py.
- If the error mentions a specific service cannot be stopped, some core services (like systemd-journald.service) resist being stopped. Try a different service like cron.service.
Help me fix service management errors in my Flask dashboard.
You now have a working service management panel. You can start, stop, and restart services right from your browser without touching the terminal. Next up, you will add a log viewer that lets you read system logs filtered by service.
Add the Log Viewer
Your dashboard can show stats and manage services. But when something goes wrong on a server, the first thing you check is the logs.
In this step, you will build a log viewer that queries journalctl (the systemd journal) and displays color-coded, filterable log entries directly in the browser.
In this step, get ready to:
- Create a logs API endpoint that runs journalctl and returns structured JSON.
- Build the frontend JavaScript that fetches, formats, and renders log entries with priority-based color coding.
Create the logs API endpoint
The systemd journal stores all log messages from every service on your server. The journalctl command reads these logs, and with the --output=json flag it outputs one JSON object per line, making it easy to parse programmatically.
- In app.py, find the last line of the manage_service function (the return jsonify({'status': 'error', block), then add the following new endpoint below it:
@app.route('/api/logs')
def get_logs():
lines = request.args.get('lines', '50')
unit = request.args.get('unit', '')
cmd = ['journalctl', '--output=json', '--no-pager', '-n', lines]
if unit:
cmd.extend(['-u', unit])
result = subprocess.run(cmd, capture_output=True, text=True)
logs = []
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
try:
entry = json.loads(line)
logs.append({
'timestamp': entry.get('__REALTIME_TIMESTAMP', ''),
'unit': entry.get('_SYSTEMD_UNIT', ''),
'message': entry.get('MESSAGE', ''),
'priority': entry.get('PRIORITY', '')
})
except json.JSONDecodeError:
continue
return jsonify(logs)
What does this code do?
This endpoint reads system logs by shelling out to journalctl and parsing its JSON output.
- request.args.get('lines', '50') reads the query parameter from the URL, defaulting to 50 entries if not specified.
- The -u flag filters logs to a specific systemd unit (service), so you can isolate one service's messages.
- Each JSON line from journalctl contains fields like __REALTIME_TIMESTAMP (microseconds since epoch), _SYSTEMD_UNIT, MESSAGE, and PRIORITY (0-7, where lower is more severe).
- The try/except json.JSONDecodeError block skips any malformed lines that journalctl occasionally outputs.
- Save app.py.
Not sure where to add the code?
The new @app.route('/api/logs') decorator should sit between the end of the manage_service function and the if __name__ == '__main__': block at the bottom of the file. Make sure there is a blank line separating each function.
Help me place the logs endpoint in app.py
Add the JavaScript to fetch and display logs
The Logs tab in your HTML already has a dropdown filter, a line count input, and a scrollable container. Now you need the loadLogs() function to pull data from your new endpoint and render each entry with color coding based on priority.
- In static/app.js, find the populateUnitDropdown() function. Add the following loadLogs() function directly below it:
function loadLogs() {
var unit = document.getElementById('log-unit').value;
var lines = document.getElementById('log-lines').value;
var url = '/api/logs?lines=' + lines;
if (unit) {
url += '&unit=' + unit;
}
fetch(url)
.then(function(response) { return response.json(); })
.then(function(logs) {
var container = document.getElementById('log-container');
container.innerHTML = '';
logs.forEach(function(entry) {
var div = document.createElement('div');
div.className = 'log-entry priority-' + entry.priority;
var time = '';
if (entry.timestamp) {
var date = new Date(parseInt(entry.timestamp) / 1000);
time = date.toLocaleString();
}
div.innerHTML =
'<span class="log-time">' + time + '</span>' +
'<span class="log-unit">' + (entry.unit || 'system') + '</span>' +
'<span>' + escapeHtml(entry.message) + '</span>';
container.appendChild(div);
});
});
}
What does this code do?
- It reads the current values from the service dropdown and line count input, then builds the API URL with those as query parameters.
- Each log entry gets a CSS class like priority-3 or priority-4. Your existing CSS colors errors red and warnings yellow based on these classes.
- The timestamp from journalctl is in microseconds. Dividing by 1000 converts it to milliseconds, which JavaScript's Date constructor expects.
- The escapeHtml() function prevents log messages containing HTML characters from breaking the page layout.
- Save static/app.js.
- Restart the Flask server by pressing Ctrl+C in your terminal and running:
sudo venv/bin/python app.py
- Switch to your Windows browser and visit your dashboard.
- Click the Logs tab.
- Click Fetch Logs. You should see the last 50 system log entries displayed in a terminal-style container with timestamps, unit names, and color-coded messages.
- Select a specific service from the dropdown and click Fetch Logs again to see only that service's log entries.
Seeing an empty log container?
- Check your browser's developer console for errors. A 500 response usually means the Flask server cannot run journalctl. Make sure you started the server with sudo.
- Verify that the loadLogs() function is placed after the closing brace of populateUnitDropdown() and before formatBytes(). JavaScript function order matters here because setupTabs() references loadLogs by name.
- If the dropdown shows no services, make sure you clicked the Logs tab first. The dropdown populates when the tab is selected.
Help me debug my log viewer
Why are some log entries red or yellow?
Systemd uses priority levels 0-7 (following the syslog standard). Priorities 0-3 are emergencies, alerts, criticals, and errors. Priority 4 is a warning. Your CSS maps these to red and yellow, making critical issues stand out immediately.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app.py and static/app.js.
ⓧ I'd like to double check the full code
Here is the complete app.py at this point:
import subprocess
import json
import time
from flask import Flask, render_template, jsonify, request
import psutil
app = Flask(__name__)
psutil.cpu_percent()
@app.route('/')
def index():
return render_template('index.html')
@app.route('/api/stats')
def get_stats():
cpu_percent = psutil.cpu_percent(interval=None)
memory = psutil.virtual_memory()
disk = psutil.disk_usage('/')
net = psutil.net_io_counters()
return jsonify({
'cpu': {
'percent': cpu_percent,
'count': psutil.cpu_count()
},
'memory': {
'total': memory.total,
'used': memory.used,
'percent': memory.percent
},
'disk': {
'total': disk.total,
'used': disk.used,
'percent': disk.percent
},
'network': {
'bytes_sent': net.bytes_sent,
'bytes_recv': net.bytes_recv
}
})
@app.route('/api/services')
def get_services():
result = subprocess.run(
['systemctl', 'list-units', '--type=service', '--all',
'--no-pager', '--no-legend'],
capture_output=True, text=True
)
services = []
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
parts = line.split()
if len(parts) >= 4:
services.append({
'name': parts[0],
'load': parts[1],
'active': parts[2],
'sub': parts[3],
'description': ' '.join(parts[4:])
})
return jsonify(services)
@app.route('/api/services/<service_name>/<action>', methods=['POST'])
def manage_service(service_name, action):
if action not in ['start', 'stop', 'restart']:
return jsonify({'error': 'Invalid action'}), 400
if not service_name.endswith('.service'):
service_name += '.service'
result = subprocess.run(
['systemctl', action, service_name],
capture_output=True, text=True
)
if result.returncode == 0:
return jsonify({'status': 'success',
'message': f'Service {action}ed successfully'})
else:
return jsonify({'status': 'error',
'message': result.stderr}), 500
@app.route('/api/logs')
def get_logs():
lines = request.args.get('lines', '50')
unit = request.args.get('unit', '')
cmd = ['journalctl', '--output=json', '--no-pager', '-n', lines]
if unit:
cmd.extend(['-u', unit])
result = subprocess.run(cmd, capture_output=True, text=True)
logs = []
for line in result.stdout.strip().split('\n'):
if not line.strip():
continue
try:
entry = json.loads(line)
logs.append({
'timestamp': entry.get('__REALTIME_TIMESTAMP', ''),
'unit': entry.get('_SYSTEMD_UNIT', ''),
'message': entry.get('MESSAGE', ''),
'priority': entry.get('PRIORITY', '')
})
except json.JSONDecodeError:
continue
return jsonify(logs)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
And here is the complete static/app.js:
let refreshInterval = null;
document.addEventListener('DOMContentLoaded', function() {
setupTabs();
loadStats();
startAutoRefresh();
});
function setupTabs() {
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
buttons.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.querySelectorAll('.tab-content').forEach(function(tab) {
tab.classList.remove('active');
});
document.getElementById(btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'services') {
loadServices();
} else if (btn.dataset.tab === 'logs') {
populateUnitDropdown();
}
});
});
document.getElementById('refresh-services').addEventListener('click', loadServices);
document.getElementById('fetch-logs').addEventListener('click', loadLogs);
}
function startAutoRefresh() {
refreshInterval = setInterval(loadStats, 3000);
}
function loadStats() {
fetch('/api/stats')
.then(function(response) { return response.json(); })
.then(function(data) {
document.getElementById('cpu-percent').textContent = data.cpu.percent + '%';
document.getElementById('cpu-cores').textContent = data.cpu.count + ' cores';
document.getElementById('mem-percent').textContent = data.memory.percent + '%';
document.getElementById('mem-detail').textContent =
formatBytes(data.memory.used) + ' / ' + formatBytes(data.memory.total);
document.getElementById('disk-percent').textContent = data.disk.percent + '%';
document.getElementById('disk-detail').textContent =
formatBytes(data.disk.used) + ' / ' + formatBytes(data.disk.total);
document.getElementById('net-sent').textContent =
'Sent: ' + formatBytes(data.network.bytes_sent);
document.getElementById('net-recv').textContent =
'Recv: ' + formatBytes(data.network.bytes_recv);
});
}
function loadServices() {
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var tbody = document.querySelector('#services-table tbody');
tbody.innerHTML = '';
services.forEach(function(svc) {
var statusClass = 'status-' + svc.active;
var row = document.createElement('tr');
row.innerHTML =
'<td>' + svc.name + '</td>' +
'<td class="' + statusClass + '">' + svc.active + ' (' + svc.sub + ')</td>' +
'<td>' +
'<button class="btn btn-success" onclick="manageService(\'' + svc.name + '\', \'start\')">Start</button> ' +
'<button class="btn btn-danger" onclick="manageService(\'' + svc.name + '\', \'stop\')">Stop</button> ' +
'<button class="btn" onclick="manageService(\'' + svc.name + '\', \'restart\')">Restart</button>' +
'</td>';
tbody.appendChild(row);
});
});
}
function manageService(name, action) {
fetch('/api/services/' + name + '/' + action, { method: 'POST' })
.then(function(response) { return response.json(); })
.then(function(data) {
showToast(data.message, data.status === 'error' ? 'error' : '');
loadServices();
});
}
function populateUnitDropdown() {
fetch('/api/services')
.then(function(response) { return response.json(); })
.then(function(services) {
var select = document.getElementById('log-unit');
select.innerHTML = '<option value="">All units</option>';
services.forEach(function(svc) {
if (svc.active === 'active') {
var option = document.createElement('option');
option.value = svc.name;
option.textContent = svc.name;
select.appendChild(option);
}
});
});
}
function loadLogs() {
var unit = document.getElementById('log-unit').value;
var lines = document.getElementById('log-lines').value;
var url = '/api/logs?lines=' + lines;
if (unit) {
url += '&unit=' + unit;
}
fetch(url)
.then(function(response) { return response.json(); })
.then(function(logs) {
var container = document.getElementById('log-container');
container.innerHTML = '';
logs.forEach(function(entry) {
var div = document.createElement('div');
div.className = 'log-entry priority-' + entry.priority;
var time = '';
if (entry.timestamp) {
var date = new Date(parseInt(entry.timestamp) / 1000);
time = date.toLocaleString();
}
div.innerHTML =
'<span class="log-time">' + time + '</span>' +
'<span class="log-unit">' + (entry.unit || 'system') + '</span>' +
'<span>' + escapeHtml(entry.message) + '</span>';
container.appendChild(div);
});
});
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
var sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i];
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function showToast(message, type) {
var toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (type ? ' ' + type : '');
setTimeout(function() {
toast.className = 'toast';
}, 3000);
}
Your dashboard is now complete. You can monitor system stats, manage services, and read logs all from your browser. Next up, the Secret Mission challenges you to make the stats auto-refresh in real time.
Secret mission
Add a Live Refresh Toggle
Your dashboard auto-refreshes every 3 seconds, but what if you want to freeze the numbers while investigating an issue? Real monitoring tools let you pause the live feed. Can you add a toggle button that pauses and resumes the auto-refresh, complete with a visual indicator showing whether the dashboard is live or paused?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local Ubuntu Server, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The running Flask server process (bound to port 5000).
- The ~/server-dashboard project directory (Python files, templates, static assets, virtual environment).
- System packages python3-pip and python3-venv installed via apt.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your dashboard.
- Your Flask server continues running on port 5000 and remains accessible from your Windows browser.
- Your project files stay in ~/server-dashboard ready to edit.
- The virtual environment and installed packages remain in place.
Pause - I'll come back to this later
Stop the running Flask process to free up memory and the port, but keep all project files for later.
- In the terminal where Flask is running, press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
When you are ready to resume, navigate back to the project and reactivate:
cd ~/server-dashboard
source venv/bin/activate
sudo venv/bin/python app.py
Delete - I don't want to use this again
Remove all project files and packages to start completely fresh.
- In the terminal where Flask is running, press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
- Delete the entire project directory by running:
rm -rf ~/server-dashboard
- Optionally remove the system packages you installed. Only do this if no other Python projects on your server depend on them:
sudo apt remove -y python3-pip python3-venv
- Confirm the project directory is gone by running:
ls ~/server-dashboard
You should see a message indicating the directory does not exist.
Nice Work!
Nice Work!
You just built a fully functional server management dashboard from scratch with Python and Flask. You can now monitor, manage, and troubleshoot your Ubuntu Server entirely from your browser.
You've learned how to:
- Build a Flask web server with REST API endpoints that return live system data using psutil for CPU, memory, disk, and network stats.
- Use subprocess to interact with systemctl and journalctl, giving you browser-based service management and log viewing.
- Create an HTML/CSS/JavaScript frontend with a tabbed interface that consumes your API and presents a clean, dark-themed dashboard.
- Secret Mission: You added auto-refreshing stats with JavaScript polling and non-blocking psutil calls, making the dashboard feel like a real-time monitoring tool.
Ready to quiz yourself?