← All Tutorials

Building a Cloud VM Fleet Management Panel

Infrastructure & DevOps Intermediate 40 min read #06

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

  1. The Problem: Managing a Cloud Desktop Fleet
  2. Architecture Overview
  3. Project Structure
  4. Database Schema (SQLite)
  5. Configuration and API Client
  6. Authentication and Role-Based Access
  7. The Caching Strategy
  8. API Proxy Backend
  9. Server Assignment System
  10. Power Scheduling System
  11. Admin Panel: Fleet Dashboard
  12. Agent Panel: Simple Desktop Controls
  13. Cost Analysis Engine
  14. Bulk Operations
  15. Cron Jobs and Automation
  16. Windows VM Optimization Script
  17. Production Tips and Gotchas
  18. 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:

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

  1. 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.

  2. 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.

  3. 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_multi for parallel fetching.

  4. 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:

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:

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 .env file 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:

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:

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:

  1. TTL-based: The list cache expires after 60 seconds. The details cache expires after 5 minutes.
  2. 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');
    
  3. Manual: Admins can click "Refresh" to force a full cache rebuild, or use the cache_clear endpoint:
    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:


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:


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:

  1. 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.

  2. Minute-level matching. The cron runs every minute and checks H:i equality. Since cron runs at the start of each minute and the check is exact, each schedule fires exactly once.

  3. 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.

  4. CLI guard. The php_sapi_name() !== 'cli' check prevents anyone from triggering the scheduler via the web.

  5. Audit logging. Every scheduled action is logged with initiated_by set to schedule: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:

  1. See which of their desktops are online
  2. Power on a desktop that is off
  3. Reboot a frozen desktop
  4. 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:

  1. By Datacenter — shows total cost and server count per DC
  2. By Billing Type — hourly vs. monthly split with totals
  3. 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:


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_cache is 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:

  1. Batch size. Keep curl_multi batches to 20 concurrent requests.
  2. Inter-request delay. Add usleep(500000) (500ms) between sequential power operations.
  3. 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).
  4. 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

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

  1. Change the default admin password immediately after deployment.
  2. Put the panel behind a VPN or restrict access by IP in your web server config.
  3. Move API credentials to environment variables or a file outside the web root.
  4. HTTPS. Always use HTTPS — HTTP Basic Auth sends credentials in base64 (not encrypted) with every request.
  5. The data/ directory should not be directly accessible via the web server. Add a .htaccess deny 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:

kamatera.php (~320 lines)

The API proxy. Routes 11 actions through a switch statement:

kamatera-admin.php (~970 lines)

Single-page admin panel. Features:

desktops.php (~280 lines)

Agent panel. Features:

kamatera-cron.php (~70 lines)

CLI-only power schedule executor:

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:

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.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation