Building a Cloud VM Fleet Management Panel
PHP + REST API + SQLite + Role-Based Access Control
What you will build: A complete web-based management panel for controlling a fleet of 100+ cloud virtual machines across multiple data centers. The system includes an admin dashboard with cost analysis, bulk operations, and power scheduling, plus a simplified agent panel where team members see only their assigned VMs. Everything runs on a single PHP application backed by SQLite — no heavyweight frameworks, no complex deployments.
Who this is for: Cloud administrators, PHP developers, managed service providers (MSPs), and anyone managing remote desktop fleets for distributed teams.
Prerequisites: PHP 8.x with SQLite3 and cURL extensions, a web server (Apache or Nginx), cron access, and API credentials for your cloud provider (this tutorial uses Kamatera, but the patterns apply to any REST API).
Time to build: 4-6 hours for the core system, another 2-3 hours for scheduling and cost analysis.
Table of Contents
- The Problem: Managing a Cloud Desktop Fleet
- Architecture Overview
- Project Structure
- Database Schema (SQLite)
- Configuration and API Client
- Authentication and Role-Based Access
- The Caching Strategy
- API Proxy Backend
- Server Assignment System
- Power Scheduling System
- Admin Panel: Fleet Dashboard
- Agent Panel: Simple Desktop Controls
- Cost Analysis Engine
- Bulk Operations
- Cron Jobs and Automation
- Windows VM Optimization Script
- Production Tips and Gotchas
- Complete File Reference
1. The Problem: Managing a Cloud Desktop Fleet {#1-the-problem}
When you run a business with 50, 100, or 200+ cloud desktops for remote workers, the cloud provider's native console becomes painful. You face several recurring problems:
- No role separation. The cloud console is all-or-nothing. You cannot give a team lead the ability to power on their team's machines without also giving them access to delete VMs or change billing.
- No scheduled power management. If your team works 9-to-5, those VMs are burning money overnight. You need automated power-on at 08:45 and power-off at 18:15, respecting different time zones and day-of-week rules.
- Slow, API-limited detail lookups. Most cloud APIs return minimal data from their list endpoint (name, ID, datacenter, power state) — and require a separate per-server API call to get the IP address, CPU, RAM, and billing details. With 130 servers, that is 130 sequential API calls, taking minutes.
- No cost visibility. You know the total monthly bill, but which datacenter is the most expensive? Which VMs cost the most? What is the hourly-vs-monthly billing split?
- Agent self-service. You want agents to power on their own desktops in the morning and reboot them when they freeze, without filing a support ticket.
This tutorial solves all of these problems with a lightweight PHP application.
2. Architecture Overview {#2-architecture-overview}
┌─────────────────────────────────────────────────────────────────────┐
│ USERS │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Admin User │ │ Agent User │ │
│ │ (full fleet) │ │ (assigned VMs) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ kamatera- │ │ desktops.php │ │
│ │ admin.php │ │ (Agent Panel) │ │
│ │ (Admin Panel)│ │ │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └──────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ kamatera.php │ ◄── API Proxy (11 endpoints) │
│ │ (Backend Router) │ Enforces RBAC per request │
│ └─────────┬───────────┘ │
│ │ │
│ ┌─────────┴───────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ kamatera- │ │ SQLite DB │ │
│ │ config.php │ │ kamatera.db │ │
│ │ (API client, │ │ - users │ │
│ │ auth, cache) │ │ - schedules │ │
│ └───────┬───────┘ │ - actions log │ │
│ │ │ - server notes│ │
│ ▼ └───────────────┘ │
│ ┌───────────────┐ │
│ │ File Cache │ │
│ │ details.json │ ◄── Refreshed every 5 min by cron │
│ │ (IPs, CPU, │ │
│ │ RAM, billing)│ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────┐ │
│ │ Cloud Provider REST API │ │
│ │ GET /servers (list all) │ │
│ │ GET /server/{id} (full details) │ │
│ │ PUT /server/{id}/power (on/off/restart) │ │
│ │ GET /queue (task status) │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ CRON JOBS │ │
│ │ */5 * * * * refresh-cache.sh │ ◄── Parallel detail │
│ │ * * * * * kamatera-cron.php │ fetch (curl_multi) │
│ │ (power scheduler) │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Key Design Decisions
PHP + SQLite, no framework. This is a single-purpose tool. No need for Laravel, Symfony, or a database server. SQLite handles the modest write load (user logins, schedule checks, audit logs) with zero configuration.
Two separate frontends, one backend. The admin panel (
kamatera-admin.php) and agent panel (desktops.php) both call the same API proxy (kamatera.php). Access control is enforced at the proxy level — the frontends are just UI.Aggressive caching. The cloud provider's list endpoint is cached for 60 seconds. The per-server details (which require 130 individual API calls) are cached in a JSON file refreshed every 5 minutes by cron using
curl_multifor parallel fetching.HTTP Basic Auth against SQLite. Simple, works everywhere, and the credentials are sent with every AJAX request automatically by the browser after the first prompt.
3. Project Structure {#3-project-structure}
webapp/
├── kamatera-config.php # API credentials, SQLite init, auth, cache helpers
├── kamatera.php # Backend API proxy (routes all actions)
├── kamatera-admin.php # Admin panel frontend (HTML + JS, ~970 lines)
├── desktops.php # Agent panel frontend (HTML + JS, ~280 lines)
├── kamatera-cron.php # Power schedule executor (CLI, ~70 lines)
└── data/
├── kamatera.db # SQLite database (users, schedules, action log)
├── kamatera_details_cache.json # Cached server details (IPs, CPU, RAM, billing)
├── refresh-cache.sh # Cron script to refresh details cache
└── optimize-windows.ps1 # Windows VM optimization script (644 lines)
The total codebase is under 2,000 lines across all files. Every file has a single responsibility.
4. Database Schema (SQLite) {#4-database-schema}
SQLite is initialized lazily — the first time kam_db() is called, it creates the database file and all tables if they do not exist. This means zero setup steps: just deploy the PHP files and the database creates itself.
Users Table
CREATE TABLE IF NOT EXISTS kamatera_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT DEFAULT '',
role TEXT DEFAULT 'agent' CHECK(role IN ('admin','agent')),
assigned_servers TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
Key points:
roleis eitheradmin(sees all servers, full CRUD access) oragent(sees only assigned servers, power control only).assigned_serversstores a JSON array of server UUIDs:["abc-123", "def-456"]. For admins, this column is ignored — they see everything. For agents with no assignments, it is an empty string, and they see zero servers.- Passwords are hashed with
password_hash()usingPASSWORD_DEFAULT(bcrypt as of PHP 8.x). - A default
admin/adminaccount is seeded automatically when the table is first created.
Power Schedules Table
CREATE TABLE IF NOT EXISTS kamatera_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
server_ids TEXT NOT NULL, -- JSON array of server UUIDs
action TEXT NOT NULL CHECK(action IN ('poweron','poweroff')),
schedule_time TEXT NOT NULL, -- "HH:MM" format
days TEXT NOT NULL, -- "mon,tue,wed,thu,fri" format
timezone TEXT DEFAULT 'Europe/Rome',
enabled INTEGER DEFAULT 1,
created_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
Key points:
server_idsis a JSON array so a single schedule can target 1 or 100 servers.daysis a comma-separated list of three-letter day codes. This supports any combination: weekdays only, weekends only, specific days.timezoneensures the schedule fires at the correct local time, even if the server hosting the panel is in a different timezone.enabledallows admins to disable a schedule without deleting it.
Action Audit Log
CREATE TABLE IF NOT EXISTS kamatera_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
action TEXT NOT NULL, -- "power_on", "power_off", "power_restart"
initiated_by TEXT NOT NULL, -- username or "schedule:Night Shutdown"
status TEXT DEFAULT 'pending', -- "ok", "error", "pending"
response TEXT DEFAULT '', -- raw API response JSON
created_at TEXT DEFAULT (datetime('now'))
);
Every power action — whether triggered by an admin clicking a button, an agent powering on their desktop, or a scheduled job — is logged here with the full API response. This gives you a complete audit trail.
Server Notes
CREATE TABLE IF NOT EXISTS kamatera_server_notes (
server_id TEXT PRIMARY KEY,
note TEXT DEFAULT '',
updated_by TEXT DEFAULT '',
updated_at TEXT DEFAULT (datetime('now'))
);
Internal notes like "Agent John - UK Team" that appear in the admin table and are searchable. The cloud provider does not have this feature — server names are often auto-generated UUIDs.
Database Initialization Function
function kam_db(): SQLite3 {
static $db = null;
if ($db) return $db;
$dir = dirname(KAM_DB_PATH);
if (!is_dir($dir)) mkdir($dir, 0755, true);
$db = new SQLite3(KAM_DB_PATH);
$db->busyTimeout(5000);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA foreign_keys=ON');
// Create tables (IF NOT EXISTS — safe to run every time)
$db->exec("CREATE TABLE IF NOT EXISTS kamatera_users (...)");
$db->exec("CREATE TABLE IF NOT EXISTS kamatera_schedules (...)");
$db->exec("CREATE TABLE IF NOT EXISTS kamatera_actions (...)");
$db->exec("CREATE TABLE IF NOT EXISTS kamatera_server_notes (...)");
// Seed default admin if no users exist
$count = $db->querySingle("SELECT COUNT(*) FROM kamatera_users");
if ($count == 0) {
$hash = password_hash('admin', PASSWORD_DEFAULT);
$stmt = $db->prepare(
"INSERT INTO kamatera_users (username, password_hash, display_name, role)
VALUES ('admin', :hash, 'Administrator', 'admin')"
);
$stmt->bindValue(':hash', $hash, SQLITE3_TEXT);
$stmt->execute();
}
return $db;
}
The static $db pattern ensures only one database connection per request. WAL mode allows concurrent reads during writes — important because the cron job writes to the same database while the web server is reading from it.
Schema migration pattern: If you add a column later, use PRAGMA table_info() to check whether it already exists:
$cols = [];
$res = $db->query("PRAGMA table_info(kamatera_users)");
while ($r = $res->fetchArray(SQLITE3_ASSOC)) $cols[] = $r['name'];
if (!in_array('assigned_servers', $cols)) {
$db->exec("ALTER TABLE kamatera_users ADD COLUMN assigned_servers TEXT DEFAULT ''");
}
This is the SQLite equivalent of "IF NOT EXISTS" for columns — there is no native syntax for it.
5. Configuration and API Client {#5-configuration-and-api-client}
Constants
// kamatera-config.php
define('KAM_API_BASE', 'https://console.kamatera.com/service');
define('KAM_CLIENT_ID', 'YOUR_CLIENT_ID_HERE');
define('KAM_SECRET', 'YOUR_API_SECRET_HERE');
define('KAM_DB_PATH', __DIR__ . '/data/kamatera.db');
define('KAM_CACHE_TTL', 60); // Server list cache: 60 seconds
Security note: In production, store credentials in environment variables or a
.envfile outside the web root. Never commit API keys to version control. This tutorial uses constants for clarity.
Generic API Client
The API client handles all HTTP methods (GET, POST, PUT, DELETE) and normalizes the cloud provider's inconsistent response formats:
function kam_api(string $method, string $endpoint, ?array $body = null): mixed {
$url = KAM_API_BASE . $endpoint;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'AuthClientId: ' . KAM_CLIENT_ID,
'AuthSecret: ' . KAM_SECRET,
'Content-Type: application/json',
],
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
} elseif ($method === 'PUT') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
}
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($resp === false) return null;
$data = json_decode($resp, true);
// Kamatera returns a plain integer (task ID) for power operations — wrap it
if (is_int($data)) return ['task_id' => $data];
// Kamatera returns errors in an array with 'errors' key
if (is_array($data) && isset($data['errors'])) return $data;
return is_array($data) ? $data : ['raw' => $data];
}
API quirks this handles:
- Power operations return a bare integer (the task queue ID), not JSON. We wrap it.
- The power endpoint requires
PUT, notPOST. UsingPOSTreturns HTTP 405. - "Server is already powered on" comes back as an error (code 7) — not a success. Your UI should handle this gracefully.
UUID Validation
Always validate server IDs before passing them to the API:
function kam_valid_uuid(string $s): bool {
return (bool)preg_match(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$s
);
}
JSON Response Helper
function kam_json($data, int $code = 200): never {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
The never return type (PHP 8.1+) tells static analysis tools this function always exits.
6. Authentication and Role-Based Access {#6-authentication-and-rbac}
HTTP Basic Auth Against SQLite
We use HTTP Basic Auth because it is dead simple and works automatically with fetch() — once the browser prompts for credentials, it sends them with every subsequent request:
function kam_auth(): array {
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$pass = $_SERVER['PHP_AUTH_PW'] ?? '';
if ($user === '' || $pass === '') {
kam_request_auth();
}
$db = kam_db();
$stmt = $db->prepare(
"SELECT id, username, password_hash, display_name, role, assigned_servers
FROM kamatera_users WHERE username = :u"
);
$stmt->bindValue(':u', $user, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row || !password_verify($pass, $row['password_hash'])) {
kam_request_auth('Invalid credentials');
}
// Parse assigned servers
$assigned = [];
if ($row['role'] === 'admin') {
$assigned = 'all'; // string 'all', not an array
} else {
$raw = trim($row['assigned_servers'] ?? '');
if ($raw !== '') {
$assigned = json_decode($raw, true) ?: [];
}
}
return [
'id' => $row['id'],
'username' => $row['username'],
'display_name' => $row['display_name'],
'role' => $row['role'],
'assigned_servers' => $assigned,
];
}
The returned assigned_servers value is the core of the RBAC system:
- For admins: the string
'all' - For agents: an array of UUID strings, or an empty array
Enforcement Helpers
function kam_request_auth(string $msg = 'Authentication required'): never {
header('WWW-Authenticate: Basic realm="VM Fleet Management"');
header('HTTP/1.1 401 Unauthorized');
echo $msg;
exit;
}
function kam_require_admin(array $auth): void {
if ($auth['role'] !== 'admin') {
header('HTTP/1.1 403 Forbidden');
echo json_encode(['error' => 'Admin access required']);
exit;
}
}
Where Enforcement Happens
Every endpoint in the API proxy checks permissions before doing anything:
// In kamatera.php, every request starts with:
$auth = kam_auth();
$assigned = $auth['assigned_servers'];
// Per-server access check used by details and power endpoints:
function can_access(string $id): bool {
global $assigned;
if ($assigned === 'all') return true;
return is_array($assigned) && in_array($id, $assigned);
}
// Example: power endpoint
case 'power':
$id = $body['id'] ?? '';
if (!can_access($id)) kam_json(['error' => 'Access denied to this server'], 403);
// ... proceed with power operation
The filtering also happens at the list level — agents never receive data about servers they are not assigned to:
case 'list':
$servers = kam_get_servers_enriched();
$filtered = kam_filter_servers($servers, $assigned);
kam_json($filtered);
This is defense in depth. Even if someone reverse-engineers the JavaScript and sends manual API calls with a server UUID they should not have, the backend rejects it.
7. The Caching Strategy {#7-the-caching-strategy}
This is the most critical performance component. Without caching, every page load would require 130+ API calls.
The Problem
Most cloud APIs have a two-tier data model:
| Endpoint | Returns | Speed |
|---|---|---|
GET /servers |
id, name, datacenter, power state | Fast (1 API call) |
GET /server/{id} |
CPU, RAM, disk, IPs, billing | Slow (1 call per server) |
To show a table with IP addresses and billing info, you need to call the details endpoint for every single server. With 130 servers and a 200ms API response time, that is 26 seconds of sequential requests.
The Solution: Two-Layer Cache
Layer 1: In-memory file cache for the server list (60 seconds)
define('KAM_CACHE_TTL', 60);
function kam_cache_get(string $key): ?array {
$file = sys_get_temp_dir() . '/kamatera_' . md5($key) . '.json';
if (!file_exists($file)) return null;
if (time() - filemtime($file) > KAM_CACHE_TTL) {
@unlink($file);
return null;
}
$data = @json_decode(file_get_contents($file), true);
return is_array($data) ? $data : null;
}
function kam_cache_set(string $key, array $data): void {
$file = sys_get_temp_dir() . '/kamatera_' . md5($key) . '.json';
file_put_contents($file, json_encode($data));
}
This is a simple file-based cache in /tmp. The server list (names, IDs, power states) changes rarely, so 60 seconds is a safe TTL.
Layer 2: Persistent details cache (5 minutes, cron-refreshed)
define('KAM_DETAILS_CACHE', __DIR__ . '/data/kamatera_details_cache.json');
define('KAM_DETAILS_TTL', 300);
function kam_details_cache_get(): array {
if (!file_exists(KAM_DETAILS_CACHE)) return [];
if (time() - filemtime(KAM_DETAILS_CACHE) > KAM_DETAILS_TTL) return [];
$data = @json_decode(file_get_contents(KAM_DETAILS_CACHE), true);
return is_array($data) ? $data : [];
}
function kam_details_cache_set(array $data): void {
file_put_contents(KAM_DETAILS_CACHE, json_encode($data, JSON_UNESCAPED_UNICODE));
}
This file is refreshed every 5 minutes by a cron job. It stores the full details (IPs, CPU, RAM, billing) for all servers in a single JSON file.
Merging the two layers:
function kam_get_servers_enriched(): array {
// Layer 1: basic list (cached 60s)
$list = kam_cache_get('servers');
if (!$list) {
$list = kam_api('GET', '/servers');
if ($list && !isset($list['errors'])) {
kam_cache_set('servers', $list);
} else {
return [];
}
}
// Layer 2: details cache (refreshed by cron every 5 min)
$details = kam_details_cache_get();
// Merge: add IP, CPU, RAM, disk, billing from details into each server
foreach ($list as &$srv) {
if (isset($details[$srv['id']])) {
$d = $details[$srv['id']];
$srv['cpu'] = $d['cpu'] ?? '';
$srv['ram'] = $d['ram'] ?? 0;
$srv['diskSizes'] = $d['diskSizes'] ?? [];
$srv['networks'] = $d['networks'] ?? [];
$srv['billing'] = $d['billing'] ?? '';
}
}
unset($srv);
return $list;
}
Parallel Detail Fetching with curl_multi
The cron refresh uses curl_multi to fetch details for all servers in parallel batches of 20:
// Batch-fetch details, 20 at a time
$details = kam_details_cache_get(); // Start from existing cache
$batchSize = 20;
for ($i = 0; $i < count($list); $i += $batchSize) {
$batch = array_slice($list, $i, $batchSize);
$mh = curl_multi_init();
$handles = [];
// Add all handles in this batch
foreach ($batch as $srv) {
$ch = curl_init(KAM_API_BASE . '/server/' . $srv['id']);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'AuthClientId: ' . KAM_CLIENT_ID,
'AuthSecret: ' . KAM_SECRET,
],
]);
curl_multi_add_handle($mh, $ch);
$handles[$srv['id']] = $ch;
}
// Execute all requests in parallel
do {
$status = curl_multi_exec($mh, $active);
if ($active) curl_multi_select($mh);
} while ($active && $status === CURLM_OK);
// Collect results
foreach ($handles as $sid => $ch) {
$resp = curl_multi_getcontent($ch);
$d = json_decode($resp, true);
if ($d && !isset($d['errors'])) {
$details[$sid] = $d;
}
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
}
kam_details_cache_set($details);
Why batches of 20? Most cloud APIs have rate limits. Firing 130 simultaneous requests will get you throttled or temporarily banned. Batches of 20 with implicit waits between batches (the time it takes to process each batch) keep you well within limits. With 130 servers and 20-server batches, the full refresh takes about 5 seconds instead of 26.
Cache Invalidation
Three invalidation mechanisms:
- TTL-based: The list cache expires after 60 seconds. The details cache expires after 5 minutes.
- Event-based: After a power action, the list cache is explicitly deleted so the next request fetches fresh data:
case 'power': // ... execute power action ... @unlink(sys_get_temp_dir() . '/kamatera_' . md5('servers') . '.json'); - Manual: Admins can click "Refresh" to force a full cache rebuild, or use the
cache_clearendpoint:case 'cache_clear': kam_require_admin($auth); @unlink(sys_get_temp_dir() . '/kamatera_' . md5('servers') . '.json'); @unlink(KAM_DETAILS_CACHE); kam_json(['ok' => true]);
8. API Proxy Backend {#8-api-proxy-backend}
The API proxy (kamatera.php) is the single point of contact between the frontend and the cloud provider. It routes requests based on the action query parameter, enforces RBAC, and normalizes responses.
Route Table
| Action | Method | Permission | Description |
|---|---|---|---|
list |
GET | Any user | Enriched server list (filtered by assignment) |
details |
GET | Assigned | Full server details (&id=UUID) |
power |
POST | Assigned | Power on/off/restart (body: {id, power}) |
refresh_cache |
POST | Admin | Force re-fetch all server details |
queue |
GET | Admin | Cloud provider task queue |
schedules |
GET/POST/PUT/DELETE | Admin | Power schedule CRUD |
log |
GET | Admin | Action audit log |
users |
GET/POST/PUT/DELETE | Admin | User management with server assignment |
notes |
GET/POST | Admin (write), Any (read) | Server internal notes |
cache_clear |
POST | Admin | Clear all caches |
Router Structure
// kamatera.php
require_once __DIR__ . '/kamatera-config.php';
$auth = kam_auth();
$action = $_GET['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
$assigned = $auth['assigned_servers'];
// Parse JSON body for POST/PUT/DELETE
$body = null;
if (in_array($method, ['POST', 'PUT', 'DELETE'])) {
$raw = file_get_contents('php://input');
$body = json_decode($raw, true) ?: [];
}
switch ($action) {
case 'list':
$servers = kam_get_servers_enriched();
$filtered = kam_filter_servers($servers, $assigned);
kam_json($filtered);
break;
case 'power':
if ($method !== 'POST') kam_json(['error' => 'POST required'], 405);
$id = $body['id'] ?? '';
$power = $body['power'] ?? '';
if (!kam_valid_uuid($id))
kam_json(['error' => 'Invalid server ID'], 400);
if (!in_array($power, ['on', 'off', 'restart']))
kam_json(['error' => 'Invalid power action'], 400);
if (!can_access($id))
kam_json(['error' => 'Access denied to this server'], 403);
// Get server name for audit log
$servers = kam_cache_get('servers');
$sname = $id;
if ($servers) {
foreach ($servers as $s) {
if ($s['id'] === $id) { $sname = $s['name']; break; }
}
}
// Execute the power action (PUT, not POST!)
$data = kam_api('PUT', "/server/$id/power", ['power' => $power]);
$status = isset($data['errors']) ? 'error' : 'ok';
// Audit log
kam_log_action($id, $sname, "power_$power", $auth['username'], $status, json_encode($data));
// Invalidate list cache so next refresh shows updated power state
@unlink(sys_get_temp_dir() . '/kamatera_' . md5('servers') . '.json');
kam_json($data ?? ['error' => 'API call failed']);
break;
// ... remaining cases for schedules, users, notes, etc.
}
Audit Logging
Every power action is logged with the server ID, server name, who did it, and the full API response:
function kam_log_action(
string $server_id,
string $server_name,
string $action,
string $initiated_by,
string $status = 'pending',
string $response = ''
): void {
$db = kam_db();
$stmt = $db->prepare(
"INSERT INTO kamatera_actions
(server_id, server_name, action, initiated_by, status, response)
VALUES (:sid, :sn, :a, :ib, :st, :r)"
);
$stmt->bindValue(':sid', $server_id, SQLITE3_TEXT);
$stmt->bindValue(':sn', $server_name, SQLITE3_TEXT);
$stmt->bindValue(':a', $action, SQLITE3_TEXT);
$stmt->bindValue(':ib', $initiated_by, SQLITE3_TEXT);
$stmt->bindValue(':st', $status, SQLITE3_TEXT);
$stmt->bindValue(':r', $response, SQLITE3_TEXT);
$stmt->execute();
}
The initiated_by field distinguishes between human and automated actions:
"john"— agent John clicked a button"schedule:Night Shutdown"— the cron scheduler fired this
9. Server Assignment System {#9-server-assignment-system}
The assignment system is how admins control which servers each agent can see and control.
Data Model
Each user has an assigned_servers column containing a JSON array of server UUIDs:
["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "f0e1d2c3-b4a5-6789-0fed-cba987654321"]
For admins, this column is ignored entirely — the auth function returns 'all' instead of parsing it.
Server-Side Filtering
function kam_filter_servers(array $servers, $assigned): array {
if ($assigned === 'all') return $servers;
if (empty($assigned)) return [];
return array_values(array_filter($servers, function($s) use ($assigned) {
return in_array($s['id'], $assigned);
}));
}
This filtering happens before data is sent to the browser. An agent with 5 assigned servers receives a JSON array with exactly 5 items — not 130 items with 125 marked as "hidden."
Admin Assignment UI
The admin panel provides a modal with a full checklist grouped by datacenter:
function assignServers(userId) {
const u = allUsers.find(x => x.id === userId);
if (u.role === 'admin') { toast('Admins see all servers', 'ok'); return; }
const current = parseAssigned(u.assigned_servers);
// Group servers by datacenter
const groups = {};
allServers.forEach(s => {
const dc = s.datacenter || 'Unknown';
if (!groups[dc]) groups[dc] = [];
groups[dc].push(s);
});
// Build checklist with filter, select-all, per-DC toggle
let html = '<div>';
html += '<input type="text" id="assignFilter" placeholder="Filter..." oninput="assignFilterServers()">';
html += '<button onclick="assignToggleAll(true)">Select All</button>';
html += '<button onclick="assignToggleAll(false)">Deselect All</button>';
Object.entries(groups).forEach(([dc, list]) => {
html += `<div><strong>${dc}</strong>
<button onclick="assignToggleDC('${dc}')">toggle DC</button>`;
list.forEach(s => {
const ip = getIP(s);
const checked = current.includes(s.id) ? 'checked' : '';
html += `<label>
<input type="checkbox" class="assign-cb" data-id="${s.id}" ${checked}>
${s.name} — ${ip}
</label>`;
});
html += '</div>';
});
html += '<button onclick="saveAssignment(' + userId + ')">Save</button>';
// ... render in modal
}
async function saveAssignment(userId) {
const ids = [...document.querySelectorAll('.assign-cb:checked')]
.map(cb => cb.dataset.id);
await api('users', {method: 'PUT', body: {id: userId, assigned_servers: ids}});
}
Features of the assignment modal:
- Text filter — search by server name, IP, or note to quickly find servers
- Select All / Deselect All — operates on visible (filtered) servers only
- Per-DC toggle — select or deselect all servers in a specific datacenter
- Count display — shows "23 selected" in real-time as you check/uncheck
10. Power Scheduling System {#10-power-scheduling-system}
The power scheduler automatically powers VMs on and off based on time-of-day and day-of-week rules.
Schedule CRUD (Admin API)
case 'schedules':
kam_require_admin($auth);
$db = kam_db();
if ($method === 'POST') {
$name = trim($body['name'] ?? '');
$server_ids = $body['server_ids'] ?? [];
$saction = $body['action'] ?? '';
$time = $body['schedule_time'] ?? '';
$days = $body['days'] ?? '';
$tz = $body['timezone'] ?? 'Europe/Rome';
// Validation
if ($name === '' || empty($server_ids) || !in_array($saction, ['poweron','poweroff'])) {
kam_json(['error' => 'Missing required fields'], 400);
}
if (!preg_match('/^\d{2}:\d{2}$/', $time)) {
kam_json(['error' => 'Invalid time format (HH:MM)'], 400);
}
$stmt = $db->prepare(
"INSERT INTO kamatera_schedules
(name, server_ids, action, schedule_time, days, timezone, created_by)
VALUES (:n, :si, :a, :t, :d, :tz, :cb)"
);
$stmt->bindValue(':si', json_encode($server_ids), SQLITE3_TEXT);
// ... bind other values ...
$stmt->execute();
kam_json(['ok' => true, 'id' => $db->lastInsertRowID()]);
}
// ... GET, PUT (toggle enabled), DELETE
The Cron Executor
This is the heart of the scheduling system — a PHP script that runs every minute via cron:
#!/usr/bin/env php
<?php
/**
* Power Schedule Executor
* Cron: * * * * * php /path/to/kamatera-cron.php >> /var/log/kamatera-cron.log 2>&1
*/
// Only allow CLI execution
if (php_sapi_name() !== 'cli') {
http_response_code(403);
exit(1);
}
require_once __DIR__ . '/kamatera-config.php';
$db = kam_db();
$res = $db->query("SELECT * FROM kamatera_schedules WHERE enabled = 1");
$schedules = [];
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$schedules[] = $row;
}
if (empty($schedules)) exit(0);
$now = new DateTime('now');
foreach ($schedules as $sch) {
// Convert current time to the schedule's timezone
$tz = new DateTimeZone($sch['timezone'] ?: 'Europe/Rome');
$localNow = (clone $now)->setTimezone($tz);
// Check day of week
$today = strtolower($localNow->format('D')); // "mon", "tue", etc.
$scheduledDays = array_map('trim', explode(',', strtolower($sch['days'])));
if (!in_array($today, $scheduledDays)) continue;
// Check time (match to the minute)
$currentTime = $localNow->format('H:i');
if ($currentTime !== $sch['schedule_time']) continue;
// Time to execute!
$serverIds = json_decode($sch['server_ids'], true);
if (!is_array($serverIds) || empty($serverIds)) continue;
$power = ($sch['action'] === 'poweron') ? 'on' : 'off';
$initiator = 'schedule:' . $sch['name'];
echo date('Y-m-d H:i:s') . " Executing '{$sch['name']}': $power for "
. count($serverIds) . " servers\n";
foreach ($serverIds as $sid) {
if (!kam_valid_uuid($sid)) continue;
$result = kam_api('PUT', "/server/$sid/power", ['power' => $power]);
$status = isset($result['errors']) ? 'error' : 'ok';
kam_log_action($sid, $sid, "power_$power", $initiator, $status, json_encode($result));
echo " $sid: $status\n";
// 500ms delay between API calls to avoid rate limiting
usleep(500000);
}
}
Key design points:
Timezone-aware matching. The schedule stores a timezone. The cron job converts
now()to that timezone before comparing. This means a "09:00 Europe/London" schedule fires at 09:00 London time, even if the server runs in UTC or Rome time.Minute-level matching. The cron runs every minute and checks
H:iequality. Since cron runs at the start of each minute and the check is exact, each schedule fires exactly once.Delay between API calls. A 500ms
usleep()between calls prevents hammering the API when powering on 100 servers. Total time for 100 servers: ~50 seconds, well within the 1-minute cron interval.CLI guard. The
php_sapi_name() !== 'cli'check prevents anyone from triggering the scheduler via the web.Audit logging. Every scheduled action is logged with
initiated_byset toschedule:Schedule Name, making it easy to distinguish automated actions from manual ones.
Example Schedules
| Name | Action | Time | Days | Servers |
|---|---|---|---|---|
| Morning Power On | poweron | 08:45 | mon,tue,wed,thu,fri | All 130 |
| Night Shutdown | poweroff | 18:15 | mon,tue,wed,thu,fri | All 130 |
| Weekend Shutdown | poweroff | 13:00 | sat | All 130 |
| UK Team Early Start | poweron | 07:30 | mon,tue,wed,thu,fri | 26 UK servers |
11. Admin Panel: Fleet Dashboard {#11-admin-panel}
The admin panel (kamatera-admin.php) is a single-page application with no build step — pure vanilla JavaScript and CSS.
Layout
┌─────────────────────────────────────────────────────┐
│ Header: "Kamatera Admin" [Agent View] [Logout] │
├─────────────────────────────────────────────────────┤
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 131 │ │ 128 │ │$4968│ │83/47│ Summary Cards │
│ │Total│ │ On │ │$/mo │ │H / M│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
├─────────────────────────────────────────────────────┤
│ [Search...] [DC ▾] [Power ▾] [Billing ▾] [Refresh]│
│ ┌─ 3 selected ─ [Power On] [Power Off] [Reboot] ──│ Bulk bar
├─────────────────────────────────────────────────────┤
│ ☐│ Name │Note │ IP │DC │CPU│...│
│ ☐│ DESKTOP-01 │UK Team1│ 185.x.x.x │EU-LO│ 4T│...│ Server Table
│ ☐│ DESKTOP-02 │ │ 185.x.x.x │EU-LO│ 4T│...│ (sortable,
│ ☐│ DESKTOP-03 │Milan │ 93.x.x.x │EU-ML│ 2B│...│ filterable)
│ ... │
├─────────────────────────────────────────────────────┤
│ ▶ Cost Analysis │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ By Datacenter│ │ By Billing │ │ Top 10 Most│ │
│ │ EU-LO: $3519 │ │ Hourly: $xxx │ │ Expensive │ │
│ │ EU-ML: $1084 │ │ Monthly: $xx │ │ ... │ │
│ └──────────────┘ └──────────────┘ └────────────┘ │
├───────────────────────┬─────────────────────────────┤
│ ▶ Power Schedules │ ▶ User Management │
│ [Name] [Action] [Time│ [Username] [Pass] [Name] │
│ Morning On 09:00 MWF│ admin - Administrator │
│ Night Off 18:15 MWF│ john - John (5 servers) │
├───────────────────────┼─────────────────────────────┤
│ ▶ Action Log │ ▶ Task Queue │
│ Time | Server | Act │ [Load Queue] │
│ 10:23| DESK01 | on │ │
│ 10:22| DESK02 | on │ │
└───────────────────────┴─────────────────────────────┘
Summary Cards
Four cards at the top show fleet-wide metrics:
function updateSummary() {
const total = allServers.length;
const on = allServers.filter(s => s.power === 'on').length;
document.getElementById('cTotal').textContent = total;
document.getElementById('cOnline').textContent = on;
let hourly = 0, monthly = 0;
allServers.forEach(s => {
if (s.billing === 'hourly') hourly++;
else if (s.billing) monthly++;
});
document.getElementById('cBilling').textContent = hourly + ' / ' + monthly;
const cost = estimateTotalCost();
document.getElementById('cCost').textContent = '$' + cost.toLocaleString();
}
Sortable, Filterable Table
Every column header is clickable for sorting. A toolbar provides multi-dimensional filtering:
function applyFilters() {
const q = document.getElementById('fSearch').value.toLowerCase();
const fDC = document.getElementById('fDC').value;
const fPow = document.getElementById('fPower').value;
const fBill = document.getElementById('fBilling').value;
let filtered = allServers.filter(s => {
if (q) {
const ip = getIP(s);
const note = (serverNotes[s.id]?.note || '').toLowerCase();
if (!s.name.toLowerCase().includes(q) && !ip.includes(q) && !note.includes(q))
return false;
}
if (fDC && s.datacenter !== fDC) return false;
if (fPow && s.power !== fPow) return false;
if (fBill && s.billing !== fBill) return false;
return true;
});
filtered.sort((a, b) => {
let va = getSortVal(a, sortCol);
let vb = getSortVal(b, sortCol);
if (typeof va === 'string') return va.localeCompare(vb) * sortDir;
return (va - vb) * sortDir;
});
renderTable(filtered);
}
Inline Note Editing
Click any note cell in the table to edit it in-place:
function editNote(el, serverId) {
const currentNote = serverNotes[serverId]?.note || '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'note-input';
input.value = currentNote;
el.replaceWith(input);
input.focus();
input.select();
const save = async () => {
const newNote = input.value.trim();
await api('notes', {method:'POST', body:{server_id: serverId, note: newNote}});
// Update local cache and replace input with span
serverNotes[serverId] = {note: newNote};
const span = document.createElement('span');
span.textContent = newNote || 'add note...';
span.onclick = () => editNote(span, serverId);
input.replaceWith(span);
};
input.addEventListener('blur', save);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') { input.value = currentNote; input.blur(); }
});
}
Server Details Modal
Click any server name to open a modal with full details fetched live from the API:
async function showDetails(id) {
document.getElementById('detailModal').classList.add('show');
let d = await api('details', {qs: '&id=' + id});
// Render: ID, datacenter, power, CPU, RAM, disks, networks, billing,
// traffic, managed, backup + inline note editing + power buttons
}
Collapsible Sections
Each management section (Cost Analysis, Schedules, Users, Log, Queue) is collapsible with lazy loading:
function toggleSec(hdr) {
const body = hdr.nextElementSibling;
const isOpen = body.classList.toggle('open');
// Lazy-load content when section opens
if (isOpen) {
const id = body.id;
if (id === 'secSchedules') loadSchedules();
if (id === 'secLog') loadLog();
if (id === 'secUsers') loadUsers();
if (id === 'secCost') renderCostAnalysis();
}
}
12. Agent Panel: Simple Desktop Controls {#12-agent-panel}
The agent panel (desktops.php) is intentionally minimal — a grid of cards, one per assigned desktop, with three buttons each.
Design Philosophy
Agents are not sysadmins. They need to:
- See which of their desktops are online
- Power on a desktop that is off
- Reboot a frozen desktop
- Nothing else
The UI reflects this. No tables, no cost data, no schedules, no user management. Just cards.
Card Grid with Datacenter Grouping
function renderServers() {
// Group by datacenter
const groups = {};
filtered.forEach(s => {
const dc = s.datacenter || 'Unknown';
if (!groups[dc]) groups[dc] = [];
groups[dc].push(s);
});
// Sort groups by count (largest DC first)
const sorted = Object.entries(groups).sort((a,b) => b[1].length - a[1].length);
sorted.forEach(([dc, list]) => {
list.sort((a,b) => a.name.localeCompare(b.name));
// Render DC header + responsive card grid
list.forEach(s => {
// Each card shows: name, note, IP, CPU/RAM specs, power status,
// and three buttons: Power On / Power Off / Reboot
});
});
}
Auto-Refresh
The agent panel auto-refreshes every 30 seconds so power state changes appear without manual reload:
let refreshTimer;
async function loadServers() {
const [listResp, notesResp] = await Promise.all([
fetch('kamatera.php?action=list'),
fetch('kamatera.php?action=notes')
]);
servers = await listResp.json();
renderServers();
clearTimeout(refreshTimer);
refreshTimer = setTimeout(loadServers, 30000);
}
Power Operations with Loading State
async function power(id, action, btn) {
// Confirm before destructive actions
if (action !== 'on' && !confirm(`${labels[action]} this desktop?`)) return;
// Visual loading state
btn.classList.add('loading');
card.querySelectorAll('.btn').forEach(b => b.disabled = true);
try {
const r = await fetch('kamatera.php?action=power', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id, power: action})
});
const data = await r.json();
if (r.status === 403) throw new Error('Access denied');
if (data.errors) throw new Error(data.errors[0]?.info || 'API error');
toast('Power On sent', 'ok');
setTimeout(loadServers, 3000); // Refresh after 3s to show new state
} catch(e) {
toast('Error: ' + e.message, 'err');
card.querySelectorAll('.btn').forEach(b => b.disabled = false);
}
btn.classList.remove('loading');
}
The 3-second delay before refreshing gives the cloud API time to actually change the power state.
Mobile-Responsive Layout
The card grid uses CSS grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) to adapt from 1 column on phones to 4+ columns on wide screens:
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
@media (max-width: 600px) {
.grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.btn { padding: 7px 8px; font-size: .72rem; }
}
Empty State
If an agent has no servers assigned, they see a helpful message instead of an empty page:
if (servers.length === 0) {
content.innerHTML = `
<div class="empty">
<div class="big">No desktops assigned</div>
Contact your administrator to get desktops assigned to your account.
</div>`;
return;
}
13. Cost Analysis Engine {#13-cost-analysis-engine}
The admin panel includes a cost analysis section that calculates per-VM and aggregate costs from the cached details.
Cost Estimation Formula
Since cloud providers often do not expose exact pricing via API, we estimate based on resource allocation:
function estimateCost(s) {
let cpuCores = parseInt(s.cpu) || 2;
let ramGB = (s.ram || 4096) / 1024;
let diskGB = (s.diskSizes && s.diskSizes[0]) || 50;
// Approximate pricing (adjust for your provider):
// ~$4/core + ~$3/GB RAM + ~$0.05/GB disk + ~$12 Windows license
let est = cpuCores * 4 + ramGB * 3 + diskGB * 0.05 + 12;
// Hourly billing typically costs ~10% more than monthly
if (s.billing === 'hourly') est *= 1.1;
return Math.round(est);
}
Tip: Replace these coefficients with your cloud provider's actual pricing. Some providers publish pricing as a JSON file or API endpoint that you can fetch and cache.
Breakdown Views
The cost analysis renders three cards:
- By Datacenter — shows total cost and server count per DC
- By Billing Type — hourly vs. monthly split with totals
- Top 10 Most Expensive — sorted list of the costliest VMs
function renderCostAnalysis() {
// Group by datacenter
const byDC = {};
allServers.forEach(s => {
const dc = s.datacenter;
if (!byDC[dc]) byDC[dc] = {count: 0, cost: 0};
byDC[dc].count++;
byDC[dc].cost += estimateCost(s);
});
// Sort DCs by cost descending
Object.entries(byDC)
.sort((a, b) => b[1].cost - a[1].cost)
.forEach(([dc, v]) => {
// Render: "EU-LO (96) — $3,519/mo"
});
// Top 10 most expensive
const sorted = [...allServers]
.sort((a, b) => estimateCost(b) - estimateCost(a))
.slice(0, 10);
}
14. Bulk Operations {#14-bulk-operations}
Selection System
Checkboxes in each table row track selection. A "select all" checkbox in the header selects all visible (filtered) rows:
let selected = new Set();
function toggleSel(id, checked) {
checked ? selected.add(id) : selected.delete(id);
updateBulkBar();
}
function toggleAll(checked) {
document.querySelectorAll('#srvBody input[type=checkbox]').forEach(cb => {
const id = /* extract from row */;
checked ? selected.add(id) : selected.delete(id);
cb.checked = checked;
});
updateBulkBar();
}
function updateBulkBar() {
const bar = document.getElementById('bulkBar');
if (selected.size > 0) {
bar.classList.add('show');
document.getElementById('bulkCount').textContent = selected.size + ' selected';
} else {
bar.classList.remove('show');
}
}
Bulk Execution with Rate Limiting
Bulk operations iterate through selected servers with a 300ms delay between calls:
async function bulkPower(action) {
if (!confirm(`${labels[action]} ${selected.size} servers?`)) return;
let ok = 0, fail = 0;
for (const id of selected) {
try {
const r = await api('power', {method:'POST', body:{id, power:action}});
if (r && !r.errors) ok++;
else fail++;
} catch(e) { fail++; }
// Delay between calls to avoid rate limiting
await new Promise(r => setTimeout(r, 300));
}
toast(`${labels[action]}: ${ok} ok, ${fail} failed`, fail ? 'err' : 'ok');
clearSelection();
setTimeout(loadList, 3000);
}
Safety measures:
- Confirmation dialog before any bulk action
- Power On does not confirm (turning things on is safe), but Power Off and Reboot do
- 300ms delay between API calls prevents rate limiting
- Results summary shows success/failure count
- 3-second delay before refresh allows the API to process changes
15. Cron Jobs and Automation {#15-cron-jobs}
Installation
crontab -e
Add these two lines:
# Refresh VM details cache every 5 minutes
*/5 * * * * /path/to/data/refresh-cache.sh 2>/dev/null
# Execute power schedules every minute
* * * * * php /path/to/kamatera-cron.php >> /var/log/kamatera-cron.log 2>&1
The Refresh Cache Script
The simplest possible cron script — one curl call to the admin API:
#!/bin/bash
# refresh-cache.sh
curl -s -u admin:admin -X POST \
"http://localhost:8082/kamatera.php?action=refresh_cache" \
-o /dev/null
This triggers the refresh_cache endpoint in the API proxy, which does the curl_multi batch fetch of all server details.
Note: The script authenticates as admin because
refresh_cacheis an admin-only endpoint. In production, create a dedicated service account for cron jobs.
If Running in Docker
If your PHP app runs in a Docker container, the cron job needs to execute inside the container:
* * * * * docker exec my-webapp php /var/www/html/kamatera-cron.php >> /var/log/kamatera-cron.log 2>&1
Log Rotation
The cron log file grows over time. Add a logrotate config:
# /etc/logrotate.d/kamatera-cron
/var/log/kamatera-cron.log {
weekly
rotate 4
compress
missingok
notifempty
}
16. Windows VM Optimization Script {#16-windows-optimization}
For fleets of Windows VMs, an optimization script reduces RAM usage, disk consumption, and network chatter. This script can be served as a download from the admin panel and run on each VM.
Key Optimizations (PowerShell)
The script is idempotent — safe to run multiple times. Here is a representative selection of the 37 changes it makes:
# 1. Disable unnecessary services
$services = @(
'SysMain', # Superfetch — wastes RAM on VMs
'WSearch', # Windows Search — not needed for RDP desktops
'Spooler', # Print Spooler — no printers on cloud VMs
'DiagTrack', # Telemetry
'dmwappushservice', # WAP Push
'MapsBroker', # Downloaded Maps Manager
'lfsvc', # Geolocation
'RetailDemo' # Retail demo mode
)
foreach ($svc in $services) {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s -and $s.StartType -ne 'Disabled') {
Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
$changes++
}
}
# 2. Disable Windows Defender real-time protection (reduce CPU)
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue
# 3. Set High Performance power plan
powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
# 4. Disable visual effects for better RDP performance
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects" `
-Name "VisualFXSetting" -Value 2 -Type DWord
# 5. Disable background apps
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\BackgroundAccessApplications" `
-Name "GlobalUserDisabled" -Value 1 -Type DWord
# 6. TCP optimization for low-latency RDP
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" `
-Name "TcpNoDelay" -Value 1 -Type DWord
# 7. Windows Update — notify only, do not auto-install
$wuPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
New-Item -Path $wuPath -Force | Out-Null
Set-ItemProperty -Path $wuPath -Name "NoAutoUpdate" -Value 0
Set-ItemProperty -Path $wuPath -Name "AUOptions" -Value 2 # Notify before download
# 8. Clean up disk
Remove-Item -Path "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
Typical results: 37 changes applied, 0 errors, 6+ GB disk freed per VM. On a fleet of 130 VMs, that is over 800 GB of freed disk space and measurably lower RAM usage.
17. Production Tips and Gotchas {#17-production-tips}
API Rate Limiting
Most cloud APIs enforce rate limits. Strategies to stay within them:
- Batch size. Keep
curl_multibatches to 20 concurrent requests. - Inter-request delay. Add
usleep(500000)(500ms) between sequential power operations. - Cache aggressively. The 60-second list cache and 5-minute details cache dramatically reduce API calls. Without caching, 10 admins refreshing the page would generate 1,300 API calls per minute. With caching, it is 1 call per minute (the cron refresh).
- Handle "already powered on" errors gracefully. When a schedule fires and a server is already on, the API returns an error. Log it, do not alarm on it.
Cache Invalidation Timing
After a power action, the list cache is invalidated immediately, but the new power state may take 2-5 seconds to reflect in the API. The frontend uses a 3-second delay before refreshing:
setTimeout(loadServers, 3000);
If you refresh too soon, you see the old state and the user thinks the action failed.
Bulk Operation Safety
- Always require confirmation for destructive bulk actions (Power Off, Reboot).
- Power On is safe to perform without confirmation — it is always safe to turn things on.
- Set a reasonable upper limit on bulk operations. If someone selects 500 servers and clicks "Reboot," the 300ms delay means it takes 2.5 minutes. Consider parallel execution with
curl_multifor very large fleets. - Log every action in the audit table so you can trace who did what.
SQLite Concurrency
SQLite handles concurrent reads well with WAL mode. However, writes are serialized. This is fine for our workload (a few writes per minute from cron, occasional writes from user actions), but would not scale to hundreds of concurrent writers.
If you outgrow SQLite (unlikely for a fleet management panel), the migration path is straightforward: swap new SQLite3(...) calls for PDO with MySQL/PostgreSQL.
Security Hardening
- Change the default admin password immediately after deployment.
- Put the panel behind a VPN or restrict access by IP in your web server config.
- Move API credentials to environment variables or a file outside the web root.
- HTTPS. Always use HTTPS — HTTP Basic Auth sends credentials in base64 (not encrypted) with every request.
- The
data/directory should not be directly accessible via the web server. Add a.htaccessdeny rule or configure your web server to block access.
# data/.htaccess
Deny from all
Monitoring the Cron Jobs
Check that the cron log is being written to and does not contain errors:
# Check last 20 lines of cron log
tail -20 /var/log/kamatera-cron.log
# Check cache freshness
ls -la /path/to/data/kamatera_details_cache.json
# Should show a modification time within the last 5 minutes
# Check cron is actually running
grep kamatera /var/log/syslog | tail -5
Database Maintenance
The kamatera_actions table grows continuously. Add a periodic cleanup:
-- Delete audit log entries older than 90 days
DELETE FROM kamatera_actions
WHERE created_at < datetime('now', '-90 days');
Run this weekly via cron or add it to the scheduler cron script.
18. Complete File Reference {#18-file-reference}
kamatera-config.php (~300 lines)
The foundation layer. Contains:
- API constants and credentials
kam_db()— SQLite initialization with schema creationkam_auth()— HTTP Basic Auth against SQLitekam_api()— generic REST API clientkam_cache_get/set()— file-based list cache (60s TTL)kam_details_cache_get/set()— persistent details cache (5min TTL)kam_get_servers_enriched()— merges list with cached detailskam_filter_servers()— RBAC-based server filteringkam_log_action()— audit log insertionkam_json()— JSON response helperkam_valid_uuid()— UUID format validation
kamatera.php (~320 lines)
The API proxy. Routes 11 actions through a switch statement:
list— enriched, filtered server listdetails— live server details by UUIDpower— power on/off/restart with audit loggingrefresh_cache— parallel detail fetch withcurl_multiqueue— cloud provider task queueschedules— CRUD for power scheduleslog— read audit logusers— CRUD for users including server assignmentnotes— server internal notescache_clear— manual cache invalidation
kamatera-admin.php (~970 lines)
Single-page admin panel. Features:
- Summary cards (total servers, online count, estimated cost, billing split)
- Multi-filter toolbar (search, DC, power state, billing type)
- Sortable server table with inline note editing
- Bulk power operations with selection
- Server details modal with live API fetch
- Cost analysis (by DC, by billing type, top 10 most expensive)
- Power schedule management (create, enable/disable, delete)
- User management (create, edit, delete, assign servers)
- Action audit log viewer
- Cloud provider task queue viewer
desktops.php (~280 lines)
Agent panel. Features:
- Responsive card grid grouped by datacenter
- Text search filter (name, IP, note)
- Power On / Power Off / Reboot buttons per card
- Auto-refresh every 30 seconds
- Loading animations on buttons
- Toast notifications for success/error
- Empty state message for unassigned agents
- Mobile-friendly responsive design
kamatera-cron.php (~70 lines)
CLI-only power schedule executor:
- Reads all enabled schedules from SQLite
- Converts current time to each schedule's timezone
- Matches day-of-week and hour:minute
- Executes power actions with 500ms delay between calls
- Logs every action to the audit table
- Outputs execution log to stdout (captured by cron to log file)
data/refresh-cache.sh (~3 lines)
Cron helper that triggers a cache refresh via the API proxy.
data/optimize-windows.ps1 (~644 lines)
Idempotent Windows optimization script for RDP desktop VMs. Disables unnecessary services, telemetry, visual effects, and background apps. Sets High Performance power plan and TCP optimizations for low-latency RDP.
Summary
This project demonstrates that a production-grade fleet management system does not require Kubernetes, React, or a cloud provider's SDK. With PHP, SQLite, cURL, and cron, you can build a tool that:
- Manages 130+ VMs across multiple data centers
- Enforces role-based access control with zero external dependencies
- Caches expensive API calls using parallel batch fetching
- Automates power scheduling to save on hourly billing
- Provides cost visibility across your entire fleet
- Gives agents a simple, mobile-friendly self-service portal
The entire system is under 2,000 lines of code, runs on a single server, and requires no build step, no package manager, and no database server.
Built with PHP 8.x, SQLite3, vanilla JavaScript, and CSS custom properties. No frameworks were harmed in the making of this panel.