Compare commits

..
58 changed files with 7703 additions and 289 deletions
+31
View File
@@ -80,6 +80,16 @@ services:
retries: 10
start_period: 20s
edge-broker:
build:
context: .
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
caddy:
image: caddy:2.7.6-alpine
container_name: caddy
@@ -196,11 +206,14 @@ services:
container_name: php1
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -213,11 +226,14 @@ services:
container_name: php2
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -230,11 +246,14 @@ services:
container_name: php3
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -247,11 +266,14 @@ services:
container_name: php4
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -264,11 +286,14 @@ services:
container_name: php5
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -281,11 +306,14 @@ services:
container_name: php-staging
depends_on:
- redis-staging
- edge-broker
command: ["php-fpm"]
env_file:
- .env.staging
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -298,11 +326,14 @@ services:
container_name: php-cron
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+22
View File
@@ -3,6 +3,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
"/edge-agent/install-token/status",
"report_install_status",
'begin_install_phase "VERIFY_TOKEN"',
'begin_install_phase "WAIT_FOR_CLAIM"',
'report_install_status "FAILED"',
];
export function normalizeBaseUrl(url) {
return String(url || "").trim().replace(/\/+$/, "");
@@ -63,6 +70,17 @@ export function buildChecks(baseUrl, installToken) {
];
}
export function validateInstallerScriptBody(body) {
const source = String(body || "");
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
if (missingSnippets.length) {
throw new Error(`Installer script is missing required status wiring: ${missingSnippets.join(", ")}`);
}
return INSTALLER_SCRIPT_REQUIRED_SNIPPETS;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/staging-edge-gateway-smoke.mjs --install-token <token> [--base-url <url>]
@@ -108,6 +126,10 @@ export async function runSmoke({ baseUrl, installToken }) {
`Body preview: ${result.bodyPreview || "<empty>"}`
);
}
if (check.name === "Installer script") {
result.verifiedSnippets = validateInstallerScriptBody(body);
}
}
return results;
@@ -3,9 +3,11 @@ import assert from "node:assert/strict";
import {
DEFAULT_STAGING_BASE_URL,
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
buildChecks,
normalizeBaseUrl,
parseArgs,
validateInstallerScriptBody,
} from "./staging-edge-gateway-smoke.mjs";
test("normalizeBaseUrl strips trailing slashes", () => {
@@ -35,3 +37,21 @@ test("buildChecks targets the public staging endpoints", () => {
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
]);
});
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
const script = `
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
report_install_status "FAILED"
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
`;
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
});
test("validateInstallerScriptBody rejects missing installer status hooks", () => {
assert.throws(
() => validateInstallerScriptBody("echo hello"),
/Installer script is missing required status wiring/
);
});
+397 -42
View File
@@ -55,27 +55,81 @@ function resolveAuthMode(options = {}, managerUrl = "") {
return managerUrl ? "manager" : "stub";
}
function parseScopes(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(
value
.map((scope) => String(scope || "").trim().toLowerCase())
.filter(Boolean)
)
);
}
function eventScopes(message) {
switch (message?.type) {
case "gateway.telemetry":
case "presence.changed":
return ["overview", "statistics"];
case "task.updated":
return ["tasks", "overview"];
case "log.append":
return ["logs"];
case "stats.updated":
return ["statistics"];
default:
return [];
}
}
function sessionAllowsScopes(sessionRecord, scopes) {
const subscriptions = sessionRecord.subscriptions || new Set();
if (subscriptions.has("*")) {
return true;
}
if (!scopes || scopes.length === 0) {
return true;
}
return scopes.some((scope) => subscriptions.has(scope));
}
function sendJson(ws, payload) {
if (!ws || ws.readyState !== 1) {
return false;
}
ws.send(JSON.stringify(payload));
return true;
}
export function createBrokerServer(options = {}) {
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
const agents = new Map();
const pendingCommands = new Map();
const browserSessions = new Map();
const browserShellSessions = new Map();
const browserStreamSessions = new Map();
const gatewayStreamSessions = new Map();
const inflightGatewaySyncs = new Map();
const managerRequest = async (path, body = {}) => {
const managerRequest = async (path, body = {}, method = "POST") => {
if (!managerUrl) {
throw new Error("Edge manager URL is not configured");
}
const response = await fetch(`${managerUrl}${path}`, {
method: "POST",
method,
headers: {
"content-type": "application/json",
...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}),
},
body: JSON.stringify(body),
body: method === "GET" ? undefined : JSON.stringify(body),
});
const json = await parseJsonResponse(response);
if (!response.ok) {
@@ -88,14 +142,23 @@ export function createBrokerServer(options = {}) {
const validateAgent =
options.validateAgent ||
(authMode === "stub"
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId })
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId, label: `Gateway ${gatewayId}` })
: async ({ gatewayId, token }) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/validate`, { token }));
const validateShellSession =
options.validateShellSession ||
(authMode === "stub"
? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub" })
? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub", cols: 120, rows: 32 })
: async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token }));
const markShellSessionOpened =
options.markShellSessionOpened ||
(authMode === "stub"
? async () => ({})
: async (token, connectionId) =>
managerRequest("/edge-agent/internal/shell-sessions/opened", {
token,
connection_id: connectionId,
}));
const closeShellSession =
options.closeShellSession ||
(authMode === "stub"
@@ -106,6 +169,15 @@ export function createBrokerServer(options = {}) {
transcript,
reason,
}));
const validateBrowserStream =
options.validateBrowserStream ||
(authMode === "stub"
? async ({ token }) => ({
id: token,
gateway_id: 1,
scopes: ["overview", "tasks", "logs", "statistics"],
})
: async ({ token }) => managerRequest("/edge-agent/internal/browser-streams/validate", { token }));
const reportGatewayPresence =
options.reportGatewayPresence ||
(authMode === "stub"
@@ -117,8 +189,58 @@ export function createBrokerServer(options = {}) {
reason,
metadata,
}));
const requestGatewayBacklog =
options.requestGatewayBacklog ||
(authMode === "stub"
? async () => ({ gateway: {}, dispatch: [] })
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/backlog`, payload));
const ingestTelemetry =
options.ingestTelemetry ||
(authMode === "stub"
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/telemetry`, payload));
const ingestTaskEvent =
options.ingestTaskEvent ||
(authMode === "stub"
? async (_gatewayId, _operationId, payload = {}) => payload
: async (gatewayId, operationId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/events`, payload));
const ingestTaskResult =
options.ingestTaskResult ||
(authMode === "stub"
? async (_gatewayId, _operationId, payload = {}) => payload
: async (gatewayId, operationId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/complete`, payload));
const ingestLogEntry =
options.ingestLogEntry ||
(authMode === "stub"
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
const closeBrowserSession = async (sessionRecord, reason) => {
const broadcastGatewayEvent = (gatewayId, message) => {
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
if (!sessionIds || sessionIds.size === 0) {
return;
}
const allowedScopes = eventScopes(message);
for (const sessionId of sessionIds.values()) {
const sessionRecord = browserStreamSessions.get(String(sessionId));
if (!sessionRecord) {
continue;
}
if (!sessionAllowsScopes(sessionRecord, allowedScopes)) {
continue;
}
sendJson(sessionRecord.ws, message);
}
};
const closeBrowserShellSession = async (sessionRecord, reason) => {
try {
await closeShellSession(
sessionRecord.session.id,
@@ -131,8 +253,8 @@ export function createBrokerServer(options = {}) {
}
};
const markBrowserSessionsClosed = (gatewayId, reason) => {
for (const sessionRecord of browserSessions.values()) {
const markGatewayShellSessionsClosed = (gatewayId, reason) => {
for (const sessionRecord of browserShellSessions.values()) {
if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) {
continue;
}
@@ -144,6 +266,59 @@ export function createBrokerServer(options = {}) {
}
};
const registerGatewayStreamSession = (sessionRecord) => {
const gatewayId = String(sessionRecord.session.gateway_id);
if (!gatewayStreamSessions.has(gatewayId)) {
gatewayStreamSessions.set(gatewayId, new Set());
}
gatewayStreamSessions.get(gatewayId).add(String(sessionRecord.session.id));
browserStreamSessions.set(String(sessionRecord.session.id), sessionRecord);
};
const removeGatewayStreamSession = (sessionRecord) => {
browserStreamSessions.delete(String(sessionRecord.session.id));
const gatewayId = String(sessionRecord.session.gateway_id);
const sessionIds = gatewayStreamSessions.get(gatewayId);
if (!sessionIds) {
return;
}
sessionIds.delete(String(sessionRecord.session.id));
if (sessionIds.size === 0) {
gatewayStreamSessions.delete(gatewayId);
}
};
const syncGatewayBacklog = async (gatewayId, explicitAgent = null) => {
const normalizedGatewayId = String(gatewayId);
const agent = explicitAgent || agents.get(normalizedGatewayId);
if (!agent || agent.readyState !== 1) {
return { queued: false };
}
if (inflightGatewaySyncs.has(normalizedGatewayId)) {
return inflightGatewaySyncs.get(normalizedGatewayId);
}
const syncPromise = (async () => {
const backlog = await requestGatewayBacklog(normalizedGatewayId, {
agent_instance_id: agent.agentInstanceId || null,
});
const dispatch = Array.isArray(backlog?.dispatch) ? backlog.dispatch : [];
for (const instruction of dispatch) {
sendJson(agent, instruction);
}
return {
queued: dispatch.length > 0,
dispatch,
};
})().finally(() => {
inflightGatewaySyncs.delete(normalizedGatewayId);
});
inflightGatewaySyncs.set(normalizedGatewayId, syncPromise);
return syncPromise;
};
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
@@ -174,13 +349,13 @@ export function createBrokerServer(options = {}) {
});
});
agent.send(JSON.stringify({
sendJson(agent, {
type: "COMMAND",
commandId,
commandType: body.commandType,
payload: body.payload || {},
jobId: body.jobId ?? null,
}));
});
try {
const result = await promise;
@@ -191,6 +366,18 @@ export function createBrokerServer(options = {}) {
return;
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
return;
}
const gatewayId = url.pathname.split("/")[3];
const result = await syncGatewayBacklog(gatewayId);
jsonResponse(res, 200, { ok: true, ...result });
return;
}
jsonResponse(res, 404, { error: "Not found" });
} catch (error) {
jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) });
@@ -204,6 +391,7 @@ export function createBrokerServer(options = {}) {
if (url.pathname === "/ws/agent") {
const gatewayId = String(url.searchParams.get("gatewayId") || "");
const token = String(url.searchParams.get("token") || "");
const agentInstanceId = String(url.searchParams.get("agentInstanceId") || "");
if (gatewayId === "" || token === "") {
socket.destroy();
return;
@@ -218,6 +406,7 @@ export function createBrokerServer(options = {}) {
ws.gatewayId = gatewayId;
ws.gatewayInfo = gatewayInfo;
ws.agentInstanceId = agentInstanceId || null;
ws.connectionId = randomUUID();
agents.set(gatewayId, ws);
reportGatewayPresence(gatewayId, {
@@ -225,8 +414,16 @@ export function createBrokerServer(options = {}) {
connectionId: ws.connectionId,
metadata: {
remote_address: req.socket.remoteAddress || null,
agent_instance_id: ws.agentInstanceId,
},
}).catch(() => {});
broadcastGatewayEvent(gatewayId, {
type: "presence.changed",
gatewayId,
status: "connected",
connectionId: ws.connectionId,
});
syncGatewayBacklog(gatewayId, ws).catch(() => {});
wss.emit("connection", ws, req);
});
return;
@@ -243,35 +440,64 @@ export function createBrokerServer(options = {}) {
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sessionToken = token;
ws.sessionInfo = session;
browserSessions.set(String(session.id), {
const sessionRecord = {
ws,
session,
transcript: "",
closedReason: null,
});
};
browserShellSessions.set(String(session.id), sessionRecord);
const agent = agents.get(String(session.gateway_id));
if (agent && agent.readyState === 1) {
agent.send(JSON.stringify({
sendJson(agent, {
type: "OPEN_ROOT_SHELL",
payload: {
sessionId: String(session.id),
reason: session.reason,
cols: session.metadata?.cols ?? null,
rows: session.metadata?.rows ?? null,
cols: session.cols ?? session.metadata?.cols ?? null,
rows: session.rows ?? session.metadata?.rows ?? null,
cwd: session.cwd ?? session.metadata?.cwd ?? null,
shellCommand: session.shell_command ?? session.metadata?.shell_command ?? null,
shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [],
},
}));
});
} else {
const sessionRecord = browserSessions.get(String(session.id));
if (sessionRecord) {
sessionRecord.closedReason = "agent_offline";
}
sessionRecord.closedReason = "agent_offline";
ws.close();
}
wss.emit("connection", ws, req);
});
return;
}
if (url.pathname === "/ws/browser-gateway-stream") {
const token = String(url.searchParams.get("token") || "");
if (token === "") {
socket.destroy();
return;
}
const session = await validateBrowserStream({ token, headers: req.headers });
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sessionToken = token;
ws.streamSessionInfo = session;
const sessionRecord = {
ws,
session,
subscriptions: new Set(parseScopes(session.scopes || ["overview", "tasks", "logs", "statistics"])),
};
registerGatewayStreamSession(sessionRecord);
sendJson(ws, {
type: "gateway.stream.ready",
gatewayId: String(session.gateway_id),
subscriptions: Array.from(sessionRecord.subscriptions.values()),
connected: Boolean(agents.get(String(session.gateway_id))?.readyState === 1),
});
wss.emit("connection", ws, req);
});
return;
}
} catch {
socket.destroy();
return;
@@ -282,7 +508,12 @@ export function createBrokerServer(options = {}) {
wss.on("connection", (ws) => {
ws.on("message", async (raw) => {
const message = JSON.parse(raw.toString());
let message;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (ws.gatewayId) {
if (message.type === "COMMAND_RESULT") {
@@ -300,22 +531,88 @@ export function createBrokerServer(options = {}) {
return;
}
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserSessions.get(String(message.sessionId));
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sessionRecord.ws.send(JSON.stringify({ type: "output", data: String(message.data || "") }));
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
sessionRecord.ws.send(JSON.stringify({ type: "opened" }));
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
sendJson(sessionRecord.ws, { type: "opened" });
return;
}
if (message.type === "SHELL_EXIT") {
sessionRecord.ws.send(JSON.stringify({ type: "closed", code: message.code ?? 0 }));
await closeBrowserSession(sessionRecord, "agent_exit");
browserSessions.delete(String(message.sessionId));
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
await closeBrowserShellSession(sessionRecord, "agent_exit");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
}
@@ -331,29 +628,65 @@ export function createBrokerServer(options = {}) {
return;
}
if (message.type === "input") {
agent.send(JSON.stringify({
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
}));
});
return;
}
if (message.type === "resize") {
agent.send(JSON.stringify({
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
}));
});
return;
}
if (message.type === "close") {
agent.send(JSON.stringify({
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
}));
});
}
return;
}
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
}
});
@@ -365,11 +698,20 @@ export function createBrokerServer(options = {}) {
if (agents.get(String(ws.gatewayId)) === ws) {
agents.delete(String(ws.gatewayId));
}
markBrowserSessionsClosed(String(ws.gatewayId), "agent_disconnected");
markGatewayShellSessionsClosed(String(ws.gatewayId), "agent_disconnected");
broadcastGatewayEvent(String(ws.gatewayId), {
type: "presence.changed",
gatewayId: String(ws.gatewayId),
status: "disconnected",
reason: closeReason || "agent_disconnected",
});
reportGatewayPresence(String(ws.gatewayId), {
status: "disconnected",
connectionId: ws.connectionId || null,
reason: closeReason || "agent_disconnected",
metadata: {
agent_instance_id: ws.agentInstanceId || null,
},
}).catch(() => {});
return;
}
@@ -378,15 +720,23 @@ export function createBrokerServer(options = {}) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (agent && agent.readyState === 1) {
agent.send(JSON.stringify({
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
}));
});
}
const sessionRecord = browserSessions.get(sessionId);
const sessionRecord = browserShellSessions.get(sessionId);
if (sessionRecord) {
await closeBrowserSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
browserSessions.delete(sessionId);
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
browserShellSessions.delete(sessionId);
}
return;
}
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (sessionRecord) {
removeGatewayStreamSession(sessionRecord);
}
}
});
@@ -404,7 +754,10 @@ export function createBrokerServer(options = {}) {
for (const agent of agents.values()) {
agent.terminate();
}
for (const session of browserSessions.values()) {
for (const session of browserShellSessions.values()) {
session.ws.terminate();
}
for (const session of browserStreamSessions.values()) {
session.ws.terminate();
}
for (const pending of pendingCommands.values()) {
@@ -426,7 +779,9 @@ export function createBrokerServer(options = {}) {
},
state: {
agents,
browserSessions,
browserShellSessions,
browserStreamSessions,
gatewayStreamSessions,
pendingCommands,
managerUrl,
authMode,
+135
View File
@@ -215,3 +215,138 @@ test("broker closes browser shell sessions when the agent disconnects before she
await broker.close();
});
test("broker syncs queued gateway backlog on agent connect and manual sync", async () => {
const backlogRequests = [];
const broker = createBrokerServer({
authMode: "stub",
sharedSecret: "secret",
requestGatewayBacklog: async (gatewayId, payload) => {
backlogRequests.push({ gatewayId, payload });
return {
dispatch: [
{
type: "TASK_DISPATCH",
taskType: "OPERATION",
operation: {
id: 91,
type: "DISCOVERY",
request: {},
},
},
],
};
},
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(
`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token&agentInstanceId=instance-1`
);
const messages = collectMessages(agent);
await new Promise((resolve) => agent.once("open", resolve));
await waitFor(
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
{ description: "initial backlog dispatch" }
);
assert.equal(backlogRequests.length, 1);
assert.equal(backlogRequests[0].gatewayId, "701");
assert.equal(backlogRequests[0].payload.agent_instance_id, "instance-1");
const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-edge-broker-secret": "secret",
},
body: JSON.stringify({ gatewayId: 701 }),
});
const json = await response.json();
assert.equal(response.status, 200);
assert.equal(json.ok, true);
await waitFor(() => backlogRequests.length >= 2, { description: "manual sync backlog request" });
agent.terminate();
await broker.close();
});
test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
validateBrowserStream: async () => ({
id: "stream-1",
gateway_id: "701",
scopes: ["overview", "tasks", "logs", "statistics"],
}),
ingestTelemetry: async (_gatewayId, payload) => ({ gateway: { id: 701, metadata: payload.metadata || {} } }),
ingestTaskEvent: async (_gatewayId, operationId, payload) => ({
id: operationId,
status: "IN_PROGRESS",
latest_event: payload,
}),
ingestLogEntry: async (_gatewayId, payload) => ({
id: 5001,
...payload,
}),
});
const address = await broker.listen(0);
const port = address.port;
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
const browserMessages = collectMessages(browser);
await new Promise((resolve) => browser.once("open", resolve));
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "TELEMETRY",
payload: {
status: "ONLINE",
metadata: {
broker_connected: true,
},
},
})
);
agent.send(
JSON.stringify({
type: "TASK_EVENT",
operationId: 41,
payload: {
level: "INFO",
code: "DISCOVERY_RUNNING",
message: "Discovery is running",
},
})
);
agent.send(
JSON.stringify({
type: "LOG_FRAME",
payload: {
level: "INFO",
stream: "agent",
source: "EDGE_AGENT",
message: "Gateway heartbeat acknowledged",
},
})
);
await waitFor(
() => browserMessages.some((message) => message.type === "presence.changed" && message.status === "connected"),
{ description: "presence update" }
);
assert.ok(browserMessages.some((message) => message.type === "gateway.telemetry"));
assert.ok(browserMessages.some((message) => message.type === "stats.updated"));
assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41));
assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message)));
browser.terminate();
agent.terminate();
await broker.close();
});
+20
View File
@@ -20,6 +20,17 @@ const traefikSource = [
readRequiredSource("services", "traefik", "traefik.prod.yml"),
].join("\n");
function readComposeServiceBlock(composeSource, serviceName) {
const escapedServiceName = serviceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const servicePattern = new RegExp(
`^\\s{2}${escapedServiceName}:\\n([\\s\\S]*?)(?=^\\s{2}[A-Za-z0-9_-]+:|^volumes:|^networks:|\\Z)`,
"m"
);
const match = composeSource.match(servicePattern);
assert.ok(match, `Expected docker compose service block for ${serviceName}`);
return match[0];
}
test("traefik does not expose a dedicated public edge broker port", () => {
assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
});
@@ -40,3 +51,12 @@ test("php services receive broker websocket environment defaults", () => {
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
}
});
test("base docker compose wires the broker into each php worker", () => {
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
}
});
File diff suppressed because one or more lines are too long
@@ -9,15 +9,19 @@ class department_gate_config
public string $type;
public ?string $phone_number = null;
public ?int $call_duration_threshold = null;
public ?string $relay_id = null;
public ?int $pulse_seconds = null;
/**
* @param array $config
*/
public function __construct(array $config = [])
{
$this->type = (string)($config['type'] ?? '');
$this->type = strtoupper(trim((string)($config['type'] ?? '')));
$this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null;
$this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null;
$this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null;
$this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null;
}
/**
@@ -37,6 +41,14 @@ class department_gate_config
$array['call_duration_threshold'] = $this->call_duration_threshold;
}
if ($this->relay_id !== null) {
$array['relay_id'] = $this->relay_id;
}
if ($this->pulse_seconds !== null) {
$array['pulse_seconds'] = $this->pulse_seconds;
}
return $array;
}
@@ -58,6 +70,19 @@ class department_gate_config
if ($this->call_duration_threshold === null) {
throw new Exception('Call duration threshold is required for PHONE_CALL gate type');
}
return;
}
if ($this->type === 'RELAY') {
if ($this->relay_id === null || $this->relay_id === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) {
throw new Exception('pulse_seconds must be a positive integer for RELAY gate type');
}
return;
}
throw new Exception('Unsupported gate config type: ' . $this->type);
}
}
@@ -1,45 +0,0 @@
<?php
namespace classes;
use Exception;
class edge_gateway_view_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
{
return $this->manager()->listGateways($departmentId, $includeDetail);
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,mixed>
* @throws Exception
*/
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
{
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
}
/**
* @throws Exception
*/
public function getGateway(int $gatewayId): array
{
return $this->manager()->getGateway($gatewayId);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -0,0 +1,51 @@
<?php
namespace classes;
require_once WD . '/interfaces/universal_module_i.php';
require_once WD . '/modules/edgegateway/edgegateway_c.php';
use Exception;
use interfaces\universal_module_i;
use modules\edgegateway\edgegateway_c;
class edgegateway implements universal_module_i
{
public edgegateway_c $config;
public function __construct()
{
$this->config = new edgegateway_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The edge gateway module is not enabled');
}
}
public function isEnabled(): bool
{
try {
return $this->config->enabled->isTrue();
} catch (Exception $exception) {
return false;
}
}
public function defaultReleaseChannel(): string
{
$configured = trim((string)$this->config->default_release_channel->getVariableValue());
return $configured !== '' ? $configured : 'stable';
}
public function defaultUpdateWindow(): string
{
$configured = trim((string)$this->config->default_update_window->getVariableValue());
return $configured !== '' ? $configured : '02:00-04:00';
}
}
@@ -3,7 +3,6 @@
namespace classes;
require_once WD . '/interfaces/shelly_transport_i.php';
require_once WD . '/classes/edge_gateway_manager.php';
use Exception;
use interfaces\shelly_transport_i;
@@ -3,7 +3,6 @@
namespace classes;
require_once WD . '/interfaces/shelly_transport_i.php';
require_once WD . '/classes/edge_gateway_manager.php';
require_once WD . '/classes/cloud_shelly_transport.php';
require_once WD . '/classes/gateway_shelly_transport.php';
+45
View File
@@ -108,6 +108,9 @@ spl_autoload_register(function (string $class): void {
// 1. Core folders: classes, interfaces, traits, objects, statistics
$core_folders = ['classes', 'interfaces', 'traits', 'objects', 'statistics'];
if (in_array($top, $core_folders)) {
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
$candidates[] = $base . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
}
$candidates[] = $base . $top . DIRECTORY_SEPARATOR . $relative;
}
// 2. Modules folder: explicitly starting with 'modules'
@@ -211,5 +214,47 @@ if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains(
exit;
}
// Load enabled module routes before the global route scan.
$load_enabled_module_routes = static function (): void {
$modules_path = WD . DIRECTORY_SEPARATOR . 'modules';
if (!is_dir($modules_path)) {
return;
}
$module_dirs = array_filter(scandir($modules_path), static function (string $item) use ($modules_path): bool {
return $item !== '.' && $item !== '..' && is_dir($modules_path . DIRECTORY_SEPARATOR . $item);
});
foreach ($module_dirs as $module_dir) {
$routes_path = $modules_path . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . 'routes';
if (!is_dir($routes_path)) {
continue;
}
$module_class = 'classes\\' . $module_dir;
if (!class_exists($module_class)) {
continue;
}
try {
$module = new $module_class();
if (method_exists($module, 'isEnabled') && !$module->isEnabled()) {
continue;
}
} catch (\Throwable $exception) {
continue;
}
foreach (scandir($routes_path) as $file) {
if ($file === '.' || $file === '..') {
continue;
}
require_once $routes_path . DIRECTORY_SEPARATOR . $file;
}
}
};
$load_enabled_module_routes();
// Autoload all the routes
$router->auto_load_routes(WD . '/routes');
@@ -0,0 +1,865 @@
<?php
namespace classes;
use Exception;
use objects\department_gates_o;
use objects\department_lanes_o;
use objects\department_relays_o;
use objects\departments_o;
use objects\plate_scanners_o;
use objects\plate_scans_o;
class edge_gateway_department_workspace_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listDepartmentSummaries(): array
{
$departments = (new departments_o())->list(true);
usort($departments, static function (array $left, array $right): int {
$leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX);
$rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX);
if ($leftPriority !== $rightPriority) {
return $leftPriority <=> $rightPriority;
}
return (int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0);
});
$summaries = [];
foreach ($departments as $departmentRow) {
$departmentId = (int)($departmentRow['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow);
$summaries[] = $workspace['summary'];
}
return $summaries;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
public function getDepartmentWorkspace(int $departmentId): array
{
return $this->buildDepartmentWorkspace($departmentId, true);
}
/**
* @param array<string,mixed>|null $departmentRow
* @return array<string,mixed>
* @throws Exception
*/
private function buildDepartmentWorkspace(int $departmentId, bool $includeGateways, ?array $departmentRow = null): array
{
$department = (new departments_o())->select($departmentId);
if (!$department->exists()) {
throw new Exception('Department not found');
}
$departmentPayload = [
'id' => $departmentId,
'name' => (string)$department->name->value(),
'description' => (string)$department->description->value(),
'order_priority' => (int)$department->order_priority->value(),
];
$transportMode = $this->manager()->getDepartmentTransportMode($departmentId);
$gateways = $this->manager()->listGateways($departmentId, true);
$bindingsByRelayId = $this->indexBindingsByRelayId($gateways);
$relayCatalog = $this->indexRelayCatalog($departmentId);
$consumersByRelayId = [];
$lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId);
$selfServe = $this->buildSelfServePayload($department, $lanes);
$scanners = $this->buildScannerPayloads($departmentId, $lanes);
$issues = $this->buildIssues($transportMode, $gateways, $lanes, $gates, $scanners, $selfServe);
$actions = $this->buildActions($departmentId, $gateways, $lanes, $gates, $scanners, $selfServe);
$summary = $this->buildSummary(
$departmentPayload,
$departmentRow,
$transportMode,
$gateways,
$lanes,
$gates,
$scanners,
$selfServe,
$issues
);
return [
'department' => $departmentPayload,
'summary' => $summary,
'gateways' => $includeGateways ? $gateways : [],
'lanes' => $lanes,
'self_serve' => $selfServe,
'gates' => $gates,
'scanners' => $scanners,
'issues' => $issues,
'actions' => $actions,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,array<int,array<string,mixed>>>
*/
private function indexBindingsByRelayId(array $gateways): array
{
$bindingsByRelayId = [];
foreach ($gateways as $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$binding['gateway_label'] = (string)($gateway['label'] ?? ('Gateway ' . ($gateway['id'] ?? '')));
$binding['gateway_status'] = (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE);
$binding['is_primary_gateway'] = (bool)($gateway['is_primary'] ?? false);
$bindingsByRelayId[$relayId][] = $binding;
}
}
foreach ($bindingsByRelayId as $relayId => $bindings) {
usort($bindings, static function (array $left, array $right): int {
return ((int)($right['is_primary_gateway'] ?? 0) <=> (int)($left['is_primary_gateway'] ?? 0))
?: ((int)($left['gateway_id'] ?? 0) <=> (int)($right['gateway_id'] ?? 0));
});
$bindingsByRelayId[$relayId] = $bindings;
}
return $bindingsByRelayId;
}
/**
* @return array<string,array<string,mixed>>
* @throws Exception
*/
private function indexRelayCatalog(int $departmentId): array
{
$catalog = [];
foreach ((new department_relays_o())->getDepartmentRelays($departmentId) as $relay) {
if (!$relay->exists()) {
continue;
}
$relayId = trim((string)$relay->relay_id->value());
if ($relayId === '') {
continue;
}
$catalog[$relayId] = $relay->asArray();
}
return $catalog;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildLanePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$lanes = [];
$slotMap = [
'relay_in_id' => 'ENTRY',
'relay_out_id' => 'EXIT',
'relay_machine_id' => 'MACHINE',
'relay_machine_program_picker_id' => 'PROGRAM_PICKER',
'relay_machine_cleaner_id' => 'CLEANER',
];
foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) {
if (!$lane->exists()) {
continue;
}
$relaySlots = [];
$boundRelayCount = 0;
foreach ($slotMap as $property => $slotName) {
$relayId = trim((string)$lane->{$property}->value());
if ($relayId === '') {
continue;
}
$consumersByRelayId[$relayId][] = [
'type' => 'lane',
'id' => (int)$lane->id,
'slot' => $slotName,
'label' => (string)$lane->name->value(),
];
$coverage = $this->buildRelayCoverage($relayId, $bindingsByRelayId);
if ((bool)($coverage['covered'] ?? false)) {
$boundRelayCount += 1;
}
$relaySlots[] = [
'slot' => $slotName,
'relay_id' => $relayId,
'catalog' => $relayCatalog[$relayId] ?? null,
'coverage' => $coverage,
];
}
$requiredRelayCount = count($relaySlots);
$laneStatus = 'UNKNOWN';
try {
$laneStatus = (string)$lane->getLaneStatus()->name;
} catch (\Throwable) {
}
$lanes[] = [
'id' => (int)$lane->id,
'department' => (int)$lane->department->value(),
'name' => (string)$lane->name->value(),
'relay_in_id' => $lane->relay_in_id->value() === null ? null : (string)$lane->relay_in_id->value(),
'relay_out_id' => $lane->relay_out_id->value() === null ? null : (string)$lane->relay_out_id->value(),
'relay_machine_id' => $lane->relay_machine_id->value() === null ? null : (string)$lane->relay_machine_id->value(),
'relay_machine_program_picker_id' => $lane->relay_machine_program_picker_id->value() === null ? null : (string)$lane->relay_machine_program_picker_id->value(),
'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(),
'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(),
'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(),
'status' => $laneStatus,
'self_serve_products' => $lane->getSelfServeLaneProducts(),
'relay_slots' => $relaySlots,
'binding_coverage' => [
'required' => $requiredRelayCount,
'bound' => $boundRelayCount,
'missing' => max(0, $requiredRelayCount - $boundRelayCount),
'state' => $requiredRelayCount === 0
? 'NOT_REQUIRED'
: ($boundRelayCount === $requiredRelayCount ? 'READY' : 'MISSING'),
],
'links' => [
'legacy' => '/superuser/department/lanes/' . (int)$lane->id,
'self_serve_studio' => '/admin/' . $departmentId . '/modules/self-serve/studio',
],
];
}
return $lanes;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildGatePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$gates = [];
foreach ((new department_gates_o())->getDepartmentGates($departmentId) as $gate) {
if (!$gate->exists()) {
continue;
}
$config = (array)$gate->config->value();
$gateType = strtoupper(trim((string)($config['type'] ?? 'UNKNOWN')));
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($gateType === 'RELAY' && $relayId !== '') {
$consumersByRelayId[$relayId][] = [
'type' => 'gate',
'id' => (int)$gate->id,
'slot' => ((bool)$gate->is_entrance->value() ? 'ENTRANCE' : ((bool)$gate->is_exit->value() ? 'EXIT' : 'GENERAL')),
'label' => (string)$gate->name->value(),
];
}
$coverage = $gateType === 'RELAY' && $relayId !== ''
? $this->buildRelayCoverage($relayId, $bindingsByRelayId)
: null;
$gates[] = [
'id' => (int)$gate->id,
'department' => (int)$gate->department->value(),
'name' => (string)$gate->name->value(),
'is_entrance' => (bool)$gate->is_entrance->value(),
'is_exit' => (bool)$gate->is_exit->value(),
'config' => $config,
'transport_type' => $gateType,
'config_complete' => $this->isGateConfigComplete($config),
'relay' => $relayId !== '' ? ($relayCatalog[$relayId] ?? ['relay_id' => $relayId]) : null,
'coverage' => $coverage,
];
}
return $gates;
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<string,mixed>
* @throws Exception
*/
private function buildSelfServePayload(departments_o $department, array $lanes): array
{
$enabled = false;
try {
$enabled = $department->getSelfServeEnabled();
} catch (\Throwable) {
}
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
}));
$taskRows = (new \objects\department_selfserve_tasks_o())->getFieldsWhere([
'department' => (int)$department->id,
'deleted_at' => null,
], ['id', 'lane', 'product']);
$productIds = [];
foreach ($taskRows as $taskRow) {
if (isset($taskRow['product'])) {
$productIds[(int)$taskRow['product']] = true;
}
}
return [
'enabled' => $enabled,
'lane_count' => count($lanes),
'ready_lanes' => count($readyLanes),
'configured_task_count' => count($taskRows),
'configured_product_count' => count($productIds),
'readiness_state' => !$enabled
? 'DISABLED'
: (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')),
'links' => [
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
'legacy' => '/superuser/selfserve',
],
];
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildScannerPayloads(int $departmentId, array $lanes): array
{
$laneIndex = [];
foreach ($lanes as $lane) {
$laneIndex[(int)$lane['id']] = $lane;
}
$recentScansByScannerId = $this->groupRecentScansByScannerId($departmentId);
$scanners = [];
foreach ((new plate_scanners_o())->getDepartmentScanners($departmentId) as $scanner) {
if (!$scanner->exists()) {
continue;
}
$scannerPayload = $scanner->asArray();
$laneId = isset($scannerPayload['lane_id']) ? (int)($scannerPayload['lane_id'] ?? 0) : 0;
$assignedLane = $laneId > 0 ? ($laneIndex[$laneId] ?? null) : null;
$recentScans = $recentScansByScannerId[(int)$scanner->id] ?? [];
$recentScanAt = $recentScans !== [] ? ($recentScans[0]['created_at'] ?? null) : null;
$assignmentState = $laneId <= 0
? 'UNASSIGNED'
: ($assignedLane === null
? 'INVALID'
: (((int)($assignedLane['binding_coverage']['missing'] ?? 0) === 0) ? 'READY' : 'PARTIAL'));
$scanners[] = [
...$scannerPayload,
'assigned_lane' => $assignedLane,
'assignment_state' => $assignmentState,
'recent_scan_at' => $recentScanAt,
'recent_scans' => $recentScans,
'recent_scan_count' => count($recentScans),
];
}
return $scanners;
}
/**
* @return array<string,array<int,array<string,mixed>>>
*/
private function groupRecentScansByScannerId(int $departmentId): array
{
$rows = (new plate_scans_o())->getFieldsWhere([
'department_id' => $departmentId,
], ['id', 'plate_scanner_id', 'plate', 'bay_id', 'created_at']);
usort($rows, static function (array $left, array $right): int {
$rightTimestamp = strtotime((string)($right['created_at'] ?? '')) ?: 0;
$leftTimestamp = strtotime((string)($left['created_at'] ?? '')) ?: 0;
return $rightTimestamp <=> $leftTimestamp ?: ((int)($right['id'] ?? 0) <=> (int)($left['id'] ?? 0));
});
$grouped = [];
foreach ($rows as $row) {
$scannerId = (int)($row['plate_scanner_id'] ?? 0);
if ($scannerId <= 0) {
continue;
}
if (!isset($grouped[$scannerId])) {
$grouped[$scannerId] = [];
}
if (count($grouped[$scannerId]) >= 5) {
continue;
}
$grouped[$scannerId][] = [
'id' => (int)($row['id'] ?? 0),
'plate' => (string)($row['plate'] ?? ''),
'bay_id' => isset($row['bay_id']) ? (string)$row['bay_id'] : null,
'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null,
];
}
return $grouped;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildIssues(
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$issues = [];
if ($gateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_GATEWAY',
'message' => 'No edge gateway has been claimed for this department.',
];
}
$onlineGateways = array_values(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY && $onlineGateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_ONLINE_GATEWAY',
'message' => 'Gateway transport mode is enabled, but no department gateway is currently online.',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$issues[] = [
'severity' => 'warning',
'code' => 'LANE_BINDING_GAP',
'message' => 'Lane ' . (string)$lane['name'] . ' is missing relay bindings.',
'target_type' => 'lane',
'target_id' => (int)$lane['id'],
];
}
}
foreach ($gates as $gate) {
if (!($gate['config_complete'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_CONFIG_INCOMPLETE',
'message' => 'Gate ' . (string)$gate['name'] . ' has incomplete transport configuration.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
continue;
}
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_BINDING_MISSING',
'message' => 'Gate ' . (string)$gate['name'] . ' is assigned to an unbound relay.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$issues[] = [
'severity' => 'warning',
'code' => 'SCANNER_UNASSIGNED',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is not assigned to a default lane.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
} elseif (($scanner['assignment_state'] ?? '') === 'PARTIAL') {
$issues[] = [
'severity' => 'info',
'code' => 'SCANNER_LANE_PARTIAL',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is assigned to a lane with missing relay coverage.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
$issues[] = [
'severity' => 'warning',
'code' => 'SELFSERVE_PARTIAL_READY',
'message' => 'Self-serve is enabled, but one or more lanes are missing required relay coverage.',
];
}
return $issues;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildActions(
int $departmentId,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$actions = [
[
'code' => 'OPEN_GATEWAY_TAB',
'label' => 'Open gateway controls',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gateways',
],
];
if ($gateways === []) {
$actions[] = [
'code' => 'INSTALL_GATEWAY',
'label' => 'Install first edge gateway',
'path' => '/superuser/configuration/edgegateway',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$actions[] = [
'code' => 'REVIEW_LANE_BINDINGS',
'label' => 'Resolve lane bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=lanes',
];
break;
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$actions[] = [
'code' => 'REVIEW_GATE_BINDINGS',
'label' => 'Resolve gate relay bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gates',
];
break;
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$actions[] = [
'code' => 'ASSIGN_SCANNERS',
'label' => 'Assign scanners to lanes',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=scanners',
];
break;
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['lane_count'] ?? 0) > 0) {
$actions[] = [
'code' => 'OPEN_SELFSERVE_STUDIO',
'label' => 'Open self-serve studio',
'path' => '/admin/' . $departmentId . '/modules/self-serve/studio',
];
}
return $actions;
}
/**
* @param array<string,mixed> $departmentPayload
* @param array<string,mixed>|null $departmentRow
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @param array<int,array<string,mixed>> $issues
* @return array<string,mixed>
*/
private function buildSummary(
array $departmentPayload,
?array $departmentRow,
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe,
array $issues
): array {
$onlineGatewayCount = count(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
$primaryGateway = null;
foreach ($gateways as $gateway) {
if (!empty($gateway['is_primary'])) {
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
break;
}
}
if ($primaryGateway === null && $gateways !== []) {
$gateway = $gateways[0];
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
}
$requiredRelayIds = [];
$coveredRelayIds = [];
foreach ($lanes as $lane) {
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
$relayId = trim((string)($slot['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($slot['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') !== 'RELAY') {
continue;
}
$relayId = trim((string)($gate['relay']['relay_id'] ?? $gate['config']['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($gate['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
$assignedScannerCount = count(array_filter($scanners, static function (array $scanner): bool {
return (int)($scanner['lane_id'] ?? 0) > 0;
}));
$recentScanAt = null;
foreach ($scanners as $scanner) {
$candidate = isset($scanner['recent_scan_at']) ? (string)$scanner['recent_scan_at'] : null;
if ($candidate === null || trim($candidate) === '') {
continue;
}
if ($recentScanAt === null || strtotime($candidate) > strtotime($recentScanAt)) {
$recentScanAt = $candidate;
}
}
$relayGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'RELAY';
}));
$phoneGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'PHONE_CALL';
}));
return [
'department_id' => (int)$departmentPayload['id'],
'department_name' => (string)$departmentPayload['name'],
'order_priority' => (int)($departmentRow['order_priority'] ?? $departmentPayload['order_priority'] ?? PHP_INT_MAX),
'transport_mode' => $transportMode,
'gateway_count' => count($gateways),
'online_gateway_count' => $onlineGatewayCount,
'primary_gateway' => $primaryGateway,
'lane_count' => count($lanes),
'self_serve_enabled' => (bool)($selfServe['enabled'] ?? false),
'self_serve_ready_lanes' => (int)($selfServe['ready_lanes'] ?? 0),
'required_relay_count' => count($requiredRelayIds),
'bound_relay_count' => count($coveredRelayIds),
'missing_binding_count' => max(0, count($requiredRelayIds) - count($coveredRelayIds)),
'gate_count' => count($gates),
'gate_transport_mix' => [
'relay' => $relayGateCount,
'phone_call' => $phoneGateCount,
],
'scanner_count' => count($scanners),
'assigned_scanner_count' => $assignedScannerCount,
'recent_scan_at' => $recentScanAt,
'issue_count' => count($issues),
'health' => $this->deriveHealthState($transportMode, $gateways, $issues),
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $issues
*/
private function deriveHealthState(string $transportMode, array $gateways, array $issues): string
{
foreach ($issues as $issue) {
if (($issue['severity'] ?? '') === 'danger') {
return 'AT_RISK';
}
}
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
foreach ($gateways as $gateway) {
if (strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE) {
return $issues === [] ? 'READY' : 'PARTIAL';
}
}
return 'AT_RISK';
}
return $issues === [] ? 'READY' : 'PARTIAL';
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @return array<string,mixed>
*/
private function buildRelayCoverage(string $relayId, array $bindingsByRelayId): array
{
$bindings = $bindingsByRelayId[$relayId] ?? [];
$primaryBinding = $bindings[0] ?? null;
return [
'relay_id' => $relayId,
'covered' => $bindings !== [],
'status' => $bindings !== [] ? 'BOUND' : 'MISSING',
'binding_count' => count($bindings),
'primary_binding' => $primaryBinding,
'bindings' => $bindings,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
*/
private function applyBindingConsumerContexts(array $gateways, array $consumersByRelayId): array
{
foreach ($gateways as $gatewayIndex => $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$metadata = isset($binding['metadata']) && is_array($binding['metadata'])
? (array)$binding['metadata']
: [];
$consumerContexts = $consumersByRelayId[$relayId] ?? [];
$metadata['consumer_contexts'] = $consumerContexts;
$metadata['consumers'] = $consumerContexts;
$bindings[$bindingIndex]['metadata'] = $metadata;
$bindings[$bindingIndex]['consumer_contexts'] = $consumerContexts;
}
$gateways[$gatewayIndex]['bindings'] = $bindings;
}
return $gateways;
}
/**
* @param array<string,mixed> $config
*/
private function isGateConfigComplete(array $config): bool
{
$type = strtoupper(trim((string)($config['type'] ?? '')));
if ($type === 'PHONE_CALL') {
return trim((string)($config['phone_number'] ?? '')) !== ''
&& isset($config['call_duration_threshold']);
}
if ($type === 'RELAY') {
return trim((string)($config['relay_id'] ?? '')) !== '';
}
return false;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -9,9 +9,11 @@ class edge_gateway_install_service
private const ARTIFACTS = [
'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
'docker-compose.gateway.yml' => 'text/yaml; charset=utf-8',
'Dockerfile.edge-agent' => 'text/plain; charset=utf-8',
'Dockerfile.lan-worker' => 'text/plain; charset=utf-8',
'Dockerfile.auto-updater' => 'text/plain; charset=utf-8',
'gateway-launcher.sh' => 'text/x-shellscript; charset=utf-8',
'truckwash-edge-gateway-stack.service' => 'text/plain; charset=utf-8',
'truckwash-edge-agent.service' => 'text/plain; charset=utf-8',
@@ -15,6 +15,8 @@ class edge_gateway_operation_service
public const STATUS_PENDING = 'PENDING';
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';
public const STATUS_CANCELLED = 'CANCELLED';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
@@ -29,6 +31,7 @@ class edge_gateway_operation_service
public const ERROR_UNSUPPORTED_VERSION = 'EDGE_GATEWAY_UNSUPPORTED_VERSION';
public const ERROR_CONFLICT = 'EDGE_GATEWAY_CONFLICT';
public const ERROR_VALIDATION = 'EDGE_GATEWAY_VALIDATION_FAILED';
public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';
public const POLL_INTERVAL_MICROSECONDS = 250000;
public const OPERATION_TIMEOUT_SECONDS = 900;
@@ -102,8 +105,8 @@ class edge_gateway_operation_service
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('PENDING', 'IN_PROGRESS')
ORDER BY FIELD(status, 'IN_PROGRESS', 'PENDING'), id ASC
AND status IN ('PENDING', 'IN_PROGRESS', 'CANCEL_REQUESTED')
ORDER BY FIELD(status, 'IN_PROGRESS', 'CANCEL_REQUESTED', 'PENDING'), id ASC
LIMIT 1"
);
$statement->execute([':gateway_id' => $gatewayId]);
@@ -126,8 +129,11 @@ class edge_gateway_operation_service
'total' => count($operations),
'pending' => 0,
'in_progress' => 0,
'cancel_requested' => 0,
'cancelled' => 0,
'completed' => 0,
'failed' => 0,
'latest_cancelled_at' => null,
'latest_completed_at' => null,
'latest_failed_at' => null,
'latest_type' => $operations[0]['type'] ?? null,
@@ -140,6 +146,11 @@ class edge_gateway_operation_service
$summary['pending'] += 1;
} elseif ($status === self::STATUS_IN_PROGRESS) {
$summary['in_progress'] += 1;
} elseif ($status === self::STATUS_CANCEL_REQUESTED) {
$summary['cancel_requested'] += 1;
} elseif ($status === self::STATUS_CANCELLED) {
$summary['cancelled'] += 1;
$summary['latest_cancelled_at'] ??= $operation['completed_at'] ?? null;
} elseif ($status === self::STATUS_COMPLETED) {
$summary['completed'] += 1;
$summary['latest_completed_at'] ??= $operation['completed_at'] ?? null;
@@ -226,6 +237,9 @@ class edge_gateway_operation_service
['operation_id' => $operationId, 'type' => $type]
);
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
}
@@ -237,6 +251,67 @@ class edge_gateway_operation_service
return $this->queueOperation($gatewayId, self::TYPE_DISCOVERY, [], $requestedBy);
}
/**
* @throws Exception
*/
public function cancelOperation(int $gatewayId, int $operationId, ?int $requestedBy = null): array
{
$gateway = $this->requireGateway($gatewayId);
$operation = $this->requireOperation($gatewayId, $operationId);
$status = (string)$operation->status->value();
if (in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED, self::STATUS_CANCELLED], true)) {
return $this->serializeOperation($operation, true);
}
$message = 'Operation cancelled by operator';
if ($status === self::STATUS_PENDING) {
$this->markOperationCancelled(
$gatewayId,
$operation,
$message,
'OPERATION_CANCELLED',
['requested_by' => $requestedBy]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_CANCELLED',
$requestedBy,
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
);
} elseif ($status === self::STATUS_IN_PROGRESS) {
$operation->status->set(self::STATUS_CANCEL_REQUESTED);
$operation->error_code->set(self::ERROR_CANCELLED);
$operation->error_message->set('Operation cancellation requested by operator');
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Cancellation requested';
$summary['retryable'] = true;
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
self::LEVEL_WARNING,
'OPERATION_CANCEL_REQUESTED',
'Operation cancellation requested by operator',
['requested_by' => $requestedBy]
);
$this->manager()->logGatewayAudit(
$gatewayId,
(int)$gateway->department_id->value(),
'GATEWAY_OPERATION_CANCEL_REQUESTED',
$requestedBy,
['operation_id' => $operationId, 'type' => (string)$operation->type->value()]
);
}
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
@@ -278,6 +353,48 @@ class edge_gateway_operation_service
return null;
}
/**
* @throws Exception
*/
public function claimBrokerOperation(int $gatewayId, ?string $agentInstanceId = null): ?array
{
$this->requireGateway($gatewayId);
$this->failTimedOutOperations($gatewayId);
return $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listBrokerCancellationRequests(int $gatewayId, ?string $agentInstanceId = null): array
{
$this->requireGateway($gatewayId);
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'status' => self::STATUS_CANCEL_REQUESTED,
'deleted_at' => null,
], ['id']);
$requestedInstance = $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId);
$operations = [];
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
if (!$operation->exists()) {
continue;
}
$claimedBy = trim((string)($operation->agent_instance_id->value() ?? ''));
if ($claimedBy !== '' && $requestedInstance !== '' && $claimedBy !== $requestedInstance) {
continue;
}
$operations[] = $this->serializeOperation($operation, true);
}
return $operations;
}
/**
* @throws Exception
*/
@@ -290,7 +407,12 @@ class edge_gateway_operation_service
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
@@ -320,9 +442,24 @@ class edge_gateway_operation_service
$operation->summary_json->set($summary);
$this->refreshOperationLease($operation);
if ($level === self::LEVEL_ERROR) {
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
}
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
public function appendBrokerOperationEvent(int $gatewayId, int $operationId, array $payload): array
{
$this->requireGateway($gatewayId);
return $this->appendOperationEventWithoutAuthentication($gatewayId, $operationId, $payload);
}
/**
* @throws Exception
*/
@@ -335,7 +472,11 @@ class edge_gateway_operation_service
}
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
@@ -343,41 +484,86 @@ class edge_gateway_operation_service
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
$errorCode = trim((string)($payload['error_code'] ?? ''));
$status = (string)$operation->status->value();
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
$errorCode = self::ERROR_CANCELLED;
}
if (!$ok && $errorCode === '') {
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
}
if (!$ok && $errorMessage === '') {
$errorMessage = 'Gateway operation failed';
$errorMessage = $errorCode === self::ERROR_CANCELLED
? 'Gateway operation cancelled'
: 'Gateway operation failed';
}
$operation->status->set($ok ? self::STATUS_COMPLETED : self::STATUS_FAILED);
$finalStatus = $ok
? self::STATUS_COMPLETED
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
? self::STATUS_CANCELLED
: self::STATUS_FAILED);
$operation->status->set($finalStatus);
$operation->result_json->set($result);
$operation->error_code->set($ok ? null : $errorCode);
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
$operation->error_message->set($ok ? null : $errorMessage);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = $ok ? 'Completed' : 'Failed';
$summary['progress'] = 100;
$summary['retryable'] = !$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION;
$summary['label'] = match ($finalStatus) {
self::STATUS_COMPLETED => 'Completed',
self::STATUS_CANCELLED => 'Cancelled',
default => 'Failed',
};
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
? max(0, min(100, (int)($summary['progress'] ?? 0)))
: 100;
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
? true
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
$ok ? self::LEVEL_INFO : self::LEVEL_ERROR,
$ok ? 'OPERATION_COMPLETED' : $errorCode,
$ok ? 'Operation completed successfully' : $errorMessage,
$finalStatus === self::STATUS_COMPLETED
? self::LEVEL_INFO
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
$finalStatus === self::STATUS_COMPLETED
? 'OPERATION_COMPLETED'
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
$finalStatus === self::STATUS_COMPLETED
? 'Operation completed successfully'
: $errorMessage,
$result
);
$this->applyCompletionSideEffects($gatewayId, $operation, $ok, $result, $errorCode, $errorMessage);
if ($finalStatus !== self::STATUS_CANCELLED) {
$this->applyCompletionSideEffects(
$gatewayId,
$operation,
$ok,
$result,
$errorCode,
$errorMessage
);
}
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
public function completeBrokerOperation(int $gatewayId, int $operationId, array $payload): array
{
$this->requireGateway($gatewayId);
return $this->completeOperationWithoutAuthentication($gatewayId, $operationId, $payload);
}
private static function normalizeOperationType(string $type): string
{
$normalized = strtoupper(trim($type));
@@ -490,6 +676,8 @@ class edge_gateway_operation_service
]
);
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
@@ -504,15 +692,20 @@ class edge_gateway_operation_service
*/
private function failTimedOutOperations(int $gatewayId): void
{
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
'gateway_id' => $gatewayId,
'status' => self::STATUS_IN_PROGRESS,
'deleted_at' => null,
], ['id']);
$statement = db::getPDO()->prepare(
"SELECT id
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND status IN ('IN_PROGRESS', 'CANCEL_REQUESTED')"
);
$statement->execute([':gateway_id' => $gatewayId]);
$rows = $statement->fetchAll();
$now = time();
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$status = (string)$operation->status->value();
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
@@ -526,6 +719,22 @@ class edge_gateway_operation_service
continue;
}
if ($status === self::STATUS_CANCEL_REQUESTED) {
$this->markOperationCancelled(
$gatewayId,
$operation,
'Gateway did not acknowledge cancellation before the operation lease expired',
'OPERATION_CANCELLED',
[
'agent_instance_id' => $operation->agent_instance_id->value(),
'last_progress_at' => $operation->last_progress_at->value(),
]
);
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
continue;
}
$errorMessage = $leaseExpired
? 'Gateway stopped reporting operation progress before the lease expired'
: 'Gateway operation timed out';
@@ -551,6 +760,9 @@ class edge_gateway_operation_service
'last_progress_at' => $operation->last_progress_at->value(),
]
);
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
}
}
@@ -730,6 +942,11 @@ class edge_gateway_operation_service
$operation->lease_expires_at->set($this->leaseExpiry());
}
private function refreshGatewayViewCache(int $gatewayId): void
{
edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId));
}
/**
* @throws Exception
*/
@@ -749,6 +966,9 @@ class edge_gateway_operation_service
}
$normalizedMessage = strtolower(trim($errorMessage));
if (str_contains($normalizedMessage, 'cancel')) {
return self::ERROR_CANCELLED;
}
if (str_contains($normalizedMessage, 'version')) {
return self::ERROR_UNSUPPORTED_VERSION;
}
@@ -783,4 +1003,192 @@ class edge_gateway_operation_service
return substr($candidate, 0, 128);
}
private function markOperationFailedFromEvent(
int $gatewayId,
edge_gateway_operations_o $operation,
?string $code,
string $message,
array $context
): void {
$operation->status->set(self::STATUS_FAILED);
$operation->error_code->set($code !== null && trim($code) !== '' ? trim($code) : self::ERROR_VALIDATION);
$operation->error_message->set($message);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Failed';
$summary['retryable'] = ((string)$operation->error_code->value()) !== self::ERROR_UNSUPPORTED_VERSION;
if (isset($context['progress'])) {
$summary['progress'] = max(0, min(100, (int)$context['progress']));
}
$operation->summary_json->set($summary);
}
private function markOperationCancelled(
int $gatewayId,
edge_gateway_operations_o $operation,
string $message,
string $eventCode,
array $context = []
): void {
$operation->status->set(self::STATUS_CANCELLED);
$operation->error_code->set(self::ERROR_CANCELLED);
$operation->error_message->set($message);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = 'Cancelled';
$summary['retryable'] = true;
$summary['progress'] = max(0, min(100, (int)($summary['progress'] ?? 0)));
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
self::LEVEL_WARNING,
$eventCode,
$message,
$context
);
}
/**
* @throws Exception
*/
private function appendOperationEventWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
{
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCEL_REQUESTED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
$level = self::LEVEL_INFO;
}
$message = trim((string)($payload['message'] ?? 'Operation event received'));
if ($message === '') {
$message = 'Operation event received';
}
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
$summary = (array)($operation->summary_json->value() ?? []);
$summary['last_event_at'] = $this->now();
$summary['last_event_message'] = $message;
if (isset($context['progress'])) {
$summary['progress'] = max(0, min(100, (int)$context['progress']));
}
if (isset($context['label']) && trim((string)$context['label']) !== '') {
$summary['label'] = trim((string)$context['label']);
}
$operation->summary_json->set($summary);
$this->refreshOperationLease($operation);
if ($level === self::LEVEL_ERROR) {
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
}
$this->refreshGatewayViewCache($gatewayId);
return $this->serializeOperation($operation, true);
}
/**
* @throws Exception
*/
private function completeOperationWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
{
$operation = $this->requireOperation($gatewayId, $operationId);
if (in_array((string)$operation->status->value(), [
self::STATUS_COMPLETED,
self::STATUS_FAILED,
self::STATUS_CANCELLED,
], true)) {
return $this->serializeOperation($operation, true);
}
$ok = (bool)($payload['ok'] ?? false);
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
$errorCode = trim((string)($payload['error_code'] ?? ''));
$status = (string)$operation->status->value();
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
$errorCode = self::ERROR_CANCELLED;
}
if (!$ok && $errorCode === '') {
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
}
if (!$ok && $errorMessage === '') {
$errorMessage = $errorCode === self::ERROR_CANCELLED
? 'Gateway operation cancelled'
: 'Gateway operation failed';
}
$finalStatus = $ok
? self::STATUS_COMPLETED
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
? self::STATUS_CANCELLED
: self::STATUS_FAILED);
$operation->status->set($finalStatus);
$operation->result_json->set($result);
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
$operation->error_message->set($ok ? null : $errorMessage);
$operation->completed_at->set($this->now());
$operation->lease_expires_at->set(null);
$operation->last_progress_at->set($this->now());
$summary = (array)($operation->summary_json->value() ?? []);
$summary['label'] = match ($finalStatus) {
self::STATUS_COMPLETED => 'Completed',
self::STATUS_CANCELLED => 'Cancelled',
default => 'Failed',
};
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
? max(0, min(100, (int)($summary['progress'] ?? 0)))
: 100;
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
? true
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
$operation->summary_json->set($summary);
$this->appendEventRecord(
$gatewayId,
$operationId,
$finalStatus === self::STATUS_COMPLETED
? self::LEVEL_INFO
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
$finalStatus === self::STATUS_COMPLETED
? 'OPERATION_COMPLETED'
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
$finalStatus === self::STATUS_COMPLETED
? 'Operation completed successfully'
: $errorMessage,
$result
);
if ($finalStatus !== self::STATUS_CANCELLED) {
$this->applyCompletionSideEffects(
$gatewayId,
$operation,
$ok,
$result,
$errorCode,
$errorMessage
);
}
$this->refreshGatewayViewCache($gatewayId);
$this->manager()->notifyBrokerGatewaySync($gatewayId);
return $this->serializeOperation($operation, true);
}
}
@@ -24,6 +24,22 @@ class edge_gateway_registry_service
return $this->manager()->verifyInstallToken($plainToken);
}
/**
* @throws Exception
*/
public function getInstallTokenStatus(int $claimTokenId): array
{
return $this->manager()->getInstallTokenStatus($claimTokenId);
}
/**
* @throws Exception
*/
public function reportInstallTokenStatus(string $plainToken, array $payload): array
{
return $this->manager()->reportInstallTokenStatus($plainToken, $payload);
}
/**
* @throws Exception
*/
@@ -181,6 +181,51 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_audit_department (department_id),
INDEX idx_edge_gateway_audit_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_log_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
department_id INT NULL,
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
stream VARCHAR(32) NOT NULL DEFAULT 'agent',
source VARCHAR(64) NOT NULL DEFAULT 'BROKER',
message TEXT NOT NULL,
context_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_edge_gateway_log_entries_gateway (gateway_id),
INDEX idx_edge_gateway_log_entries_department (department_id),
INDEX idx_edge_gateway_log_entries_level (level),
INDEX idx_edge_gateway_log_entries_stream (stream)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
department_id INT NOT NULL,
actor_user_id INT NULL,
session_token_hash CHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
reason VARCHAR(255) NULL,
connection_id VARCHAR(128) NULL,
cwd VARCHAR(255) NULL,
shell_command VARCHAR(255) NULL,
shell_args_json JSON NULL,
cols INT NULL,
terminal_rows INT NULL,
transcript LONGTEXT NULL,
metadata_json JSON NULL,
expires_at DATETIME NULL,
approved_at DATETIME NULL,
opened_at DATETIME NULL,
closed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_edge_gateway_shell_session_token_hash (session_token_hash),
INDEX idx_edge_gateway_shell_sessions_gateway (gateway_id),
INDEX idx_edge_gateway_shell_sessions_status (status),
INDEX idx_edge_gateway_shell_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
@@ -216,6 +261,34 @@ class edge_gateway_schema_bootstrap
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
self::ensureColumn('edge_gateway_log_entries', 'department_id', 'INT NULL AFTER gateway_id');
self::ensureColumn('edge_gateway_log_entries', 'level', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER department_id");
self::ensureColumn('edge_gateway_log_entries', 'stream', "VARCHAR(32) NOT NULL DEFAULT 'agent' AFTER level");
self::ensureColumn('edge_gateway_log_entries', 'source', "VARCHAR(64) NOT NULL DEFAULT 'BROKER' AFTER stream");
self::ensureColumn('edge_gateway_log_entries', 'message', 'TEXT NOT NULL AFTER source');
self::ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message');
self::ensureColumn('edge_gateway_shell_sessions', 'department_id', 'INT NOT NULL AFTER gateway_id');
self::ensureColumn('edge_gateway_shell_sessions', 'actor_user_id', 'INT NULL AFTER department_id');
self::ensureColumn('edge_gateway_shell_sessions', 'session_token_hash', 'CHAR(64) NOT NULL AFTER actor_user_id');
self::ensureColumn('edge_gateway_shell_sessions', 'status', "VARCHAR(32) NOT NULL DEFAULT 'PENDING' AFTER session_token_hash");
self::ensureColumn('edge_gateway_shell_sessions', 'reason', 'VARCHAR(255) NULL AFTER status');
self::ensureColumn('edge_gateway_shell_sessions', 'connection_id', 'VARCHAR(128) NULL AFTER reason');
self::ensureColumn('edge_gateway_shell_sessions', 'cwd', 'VARCHAR(255) NULL AFTER connection_id');
self::ensureColumn('edge_gateway_shell_sessions', 'shell_command', 'VARCHAR(255) NULL AFTER cwd');
self::ensureColumn('edge_gateway_shell_sessions', 'shell_args_json', 'JSON NULL AFTER shell_command');
self::ensureColumn('edge_gateway_shell_sessions', 'cols', 'INT NULL AFTER shell_args_json');
self::renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols');
self::ensureColumn('edge_gateway_shell_sessions', 'terminal_rows', 'INT NULL AFTER cols');
self::ensureColumn('edge_gateway_shell_sessions', 'transcript', 'LONGTEXT NULL AFTER terminal_rows');
self::ensureColumn('edge_gateway_shell_sessions', 'metadata_json', 'JSON NULL AFTER transcript');
self::ensureColumn('edge_gateway_shell_sessions', 'expires_at', 'DATETIME NULL AFTER metadata_json');
self::ensureColumn('edge_gateway_shell_sessions', 'approved_at', 'DATETIME NULL AFTER expires_at');
self::ensureColumn('edge_gateway_shell_sessions', 'opened_at', 'DATETIME NULL AFTER approved_at');
self::ensureColumn('edge_gateway_shell_sessions', 'closed_at', 'DATETIME NULL AFTER opened_at');
self::ensureColumn('edge_gateway_shell_sessions', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_shell_sessions', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
self::syncOperationTypeColumns();
self::$initialized = true;
@@ -239,6 +312,34 @@ class edge_gateway_schema_bootstrap
);
}
private static function renameColumnIfPresent(
string $table,
string $from,
string $to,
string $definition,
?string $afterColumn = null
): void {
global $db;
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
return;
}
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)
|| !preg_match('/^[A-Za-z0-9_]+$/', $from)
|| !preg_match('/^[A-Za-z0-9_]+$/', $to)
|| ($afterColumn !== null && !preg_match('/^[A-Za-z0-9_]+$/', $afterColumn))) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
}
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
$db->query(
"ALTER TABLE `$table`
CHANGE COLUMN `$from` `$to` $definition$positionClause"
);
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
@@ -0,0 +1,339 @@
<?php
namespace classes;
use Throwable;
class edge_gateway_view_cache
{
public const PREFIX = 'edge_gateway:view:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 15;
}
return max(0, (int)$raw);
}
public static function listKey(?int $departmentId = null, bool $includeDetail = true): string
{
return self::PREFIX
. 'list:department:'
. ($departmentId === null ? 'all' : (string)$departmentId)
. ':detail:'
. ($includeDetail ? '1' : '0');
}
public static function detailKey(int $gatewayId): string
{
return self::PREFIX . 'detail:' . $gatewayId;
}
/**
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}|null
*/
public static function getListPayload(?int $departmentId = null, bool $includeDetail = true): ?array
{
return self::decodeListPayload(self::redisGet(self::listKey($departmentId, $includeDetail)));
}
/**
* @param array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>} $payload
*/
public static function storeListPayload(?int $departmentId, bool $includeDetail, array $payload, ?int $ttl = null): void
{
self::storePayload(self::listKey($departmentId, $includeDetail), $payload, $ttl);
}
/**
* @return array<string,mixed>|null
*/
public static function getDetailPayload(int $gatewayId): ?array
{
$decoded = self::decodePayload(self::redisGet(self::detailKey($gatewayId)));
if (!is_array($decoded) || !isset($decoded['gateway']) || !is_array($decoded['gateway'])) {
return null;
}
return $decoded['gateway'];
}
/**
* @param array<string,mixed> $gateway
*/
public static function storeDetailPayload(int $gatewayId, array $gateway, ?int $ttl = null): void
{
self::storePayload(self::detailKey($gatewayId), ['gateway' => $gateway], $ttl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
public static function clearGateway(int $gatewayId, ?int $departmentId = null): void
{
self::clearPattern(self::detailKey($gatewayId));
self::clearPattern(self::listKey(null, true));
self::clearPattern(self::listKey(null, false));
if ($departmentId !== null) {
self::clearPattern(self::listKey($departmentId, true));
self::clearPattern(self::listKey($departmentId, false));
}
}
/**
* @param array<string,mixed> $gateway
*/
public static function syncGateway(array $gateway): void
{
$gatewayId = (int)($gateway['id'] ?? 0);
$departmentId = isset($gateway['department_id']) ? (int)$gateway['department_id'] : null;
if ($gatewayId <= 0) {
return;
}
$detailGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, true);
self::storeDetailPayload($gatewayId, $detailGateway);
self::syncListPayload(null, true, $detailGateway);
self::syncListPayload(null, false, $detailGateway);
if ($departmentId !== null && $departmentId > 0) {
self::syncListPayload($departmentId, true, $detailGateway);
self::syncListPayload($departmentId, false, $detailGateway);
}
}
public static function removeGateway(int $gatewayId, ?int $departmentId = null): void
{
self::clearPattern(self::detailKey($gatewayId));
self::removeGatewayFromListPayload(null, true, $gatewayId);
self::removeGatewayFromListPayload(null, false, $gatewayId);
if ($departmentId !== null) {
self::removeGatewayFromListPayload($departmentId, true, $gatewayId);
self::removeGatewayFromListPayload($departmentId, false, $gatewayId);
}
}
/**
* @param array<string,mixed> $gateway
*/
private static function syncListPayload(?int $departmentId, bool $includeDetail, array $gateway): void
{
$payload = self::getListPayload($departmentId, $includeDetail);
if ($payload === null) {
return;
}
$rows = isset($payload['gateways']) && is_array($payload['gateways']) ? array_values($payload['gateways']) : [];
$preparedGateway = edge_gateway_manager::prepareGatewayForListCache($gateway, $includeDetail);
$gatewayId = (int)($preparedGateway['id'] ?? 0);
$matchesDepartment = $departmentId === null
|| (int)($preparedGateway['department_id'] ?? 0) === (int)$departmentId;
if ($gatewayId <= 0 || !$matchesDepartment) {
return;
}
$updated = false;
foreach ($rows as $index => $row) {
if ((int)($row['id'] ?? 0) !== $gatewayId) {
continue;
}
$rows[$index] = self::mergeGatewayPayload($row, $preparedGateway, $includeDetail);
$updated = true;
break;
}
if (!$updated) {
$rows[] = $preparedGateway;
}
usort($rows, static fn(array $left, array $right): int => ((int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0))
?: ((int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0)));
self::storeListPayload($departmentId, $includeDetail, [
'gateways' => $rows,
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows),
]);
}
private static function removeGatewayFromListPayload(?int $departmentId, bool $includeDetail, int $gatewayId): void
{
$payload = self::getListPayload($departmentId, $includeDetail);
if ($payload === null) {
return;
}
$rows = array_values(array_filter(
isset($payload['gateways']) && is_array($payload['gateways']) ? $payload['gateways'] : [],
static fn(mixed $row): bool => (int)(is_array($row) ? ($row['id'] ?? 0) : 0) !== $gatewayId
));
self::storeListPayload($departmentId, $includeDetail, [
'gateways' => $rows,
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows($rows),
]);
}
/**
* @param array<string,mixed> $currentGateway
* @param array<string,mixed> $nextGateway
* @return array<string,mixed>
*/
private static function mergeGatewayPayload(array $currentGateway, array $nextGateway, bool $includeDetail): array
{
$merged = array_merge($currentGateway, $nextGateway);
$merged['metadata'] = array_merge(
isset($currentGateway['metadata']) && is_array($currentGateway['metadata']) ? $currentGateway['metadata'] : [],
isset($nextGateway['metadata']) && is_array($nextGateway['metadata']) ? $nextGateway['metadata'] : []
);
foreach (['inventory', 'bindings', 'recent_commands', 'audit_logs', 'operations', 'relay_health', 'diagnostics'] as $listKey) {
if (array_key_exists($listKey, $nextGateway)) {
$merged[$listKey] = $nextGateway[$listKey];
continue;
}
if (array_key_exists($listKey, $currentGateway)) {
$merged[$listKey] = $currentGateway[$listKey];
}
}
return edge_gateway_manager::prepareGatewayForListCache($merged, $includeDetail);
}
/**
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}|null
*/
private static function decodeListPayload(?string $raw): ?array
{
$decoded = self::decodePayload($raw);
if (!is_array($decoded)) {
return null;
}
if (!isset($decoded['gateways']) || !is_array($decoded['gateways'])) {
return null;
}
if (!isset($decoded['fleet_usage']) || !is_array($decoded['fleet_usage'])) {
return null;
}
return [
'gateways' => array_values($decoded['gateways']),
'fleet_usage' => $decoded['fleet_usage'],
];
}
/**
* @return array<string,mixed>|null
*/
private static function decodePayload(?string $raw): ?array
{
if ($raw === null || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string,mixed> $payload
*/
private static function storePayload(string $key, array $payload, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,78 @@
<?php
namespace classes;
use Exception;
class edge_gateway_view_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
{
return $this->listGatewaysWithFleetUsage($departmentId, $includeDetail)['gateways'];
}
/**
* @return array{gateways: array<int,array<string,mixed>>, fleet_usage: array<string,mixed>}
* @throws Exception
*/
public function listGatewaysWithFleetUsage(?int $departmentId = null, bool $includeDetail = true): array
{
$cached = edge_gateway_view_cache::getListPayload($departmentId, $includeDetail);
if ($cached !== null) {
return $cached;
}
$gateways = $this->manager()->listGateways($departmentId, $includeDetail);
$payload = [
'gateways' => $gateways,
'fleet_usage' => $this->manager()->buildFleetUsageStatistics($departmentId, $gateways),
];
edge_gateway_view_cache::storeListPayload($departmentId, $includeDetail, $payload);
return $payload;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,mixed>
* @throws Exception
*/
public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
{
if ($gateways === []) {
return $this->listGatewaysWithFleetUsage($departmentId, false)['fleet_usage'];
}
return $this->manager()->buildFleetUsageStatistics($departmentId, $gateways);
}
/**
* @throws Exception
*/
public function getGateway(int $gatewayId): array
{
$cached = edge_gateway_view_cache::getDetailPayload($gatewayId);
if ($cached !== null) {
return $cached;
}
$gateway = $this->manager()->getGateway($gatewayId);
edge_gateway_view_cache::storeDetailPayload($gatewayId, $gateway);
return $gateway;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\edgegateway\config;
use Exception;
use traits\module_config_variable;
class edgegateway_default_release_channel_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
$this->setupConfigVariable(
'edgegateway',
'default_release_channel',
'string',
true,
['stable', 'canary'],
'The default release channel assigned to newly claimed edge gateways.',
'stable',
false,
'stable'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\edgegateway\config;
use Exception;
use traits\module_config_variable;
class edgegateway_default_update_window_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
$this->setupConfigVariable(
'edgegateway',
'default_update_window',
'string',
true,
null,
'The default maintenance window applied to newly claimed edge gateways.',
'02:00-04:00',
false,
'02:00-04:00'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\edgegateway\config;
use Exception;
use traits\module_config_variable;
class edgegateway_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
$this->setupConfigVariable(
'edgegateway',
'enabled',
'bool',
true,
null,
'Whether the edge gateway module is enabled.',
'true',
false,
'true'
);
}
}
@@ -0,0 +1,34 @@
<?php
namespace modules\edgegateway;
require_once WD . '/modules/edgegateway/config/edgegateway_enabled_c.php';
require_once WD . '/modules/edgegateway/config/edgegateway_default_release_channel_c.php';
require_once WD . '/modules/edgegateway/config/edgegateway_default_update_window_c.php';
use modules\edgegateway\config\edgegateway_default_release_channel_c;
use modules\edgegateway\config\edgegateway_default_update_window_c;
use modules\edgegateway\config\edgegateway_enabled_c;
use traits\module_config_t;
class edgegateway_c
{
use module_config_t;
public edgegateway_enabled_c $enabled;
public edgegateway_default_release_channel_c $default_release_channel;
public edgegateway_default_update_window_c $default_update_window;
public function __construct()
{
$this->setupConfig('edgegateway');
$this->allowUpdate([
edgegateway_enabled_c::class,
edgegateway_default_release_channel_c::class,
edgegateway_default_update_window_c::class,
]);
$this->enabled = new edgegateway_enabled_c();
$this->default_release_channel = new edgegateway_default_release_channel_c();
$this->default_update_window = new edgegateway_default_update_window_c();
}
}
@@ -0,0 +1,56 @@
<?php
namespace routes;
use classes\authentication;
use classes\edgegateway;
use classes\response;
use objects\logs_o;
use traits\route_t;
class edgeGatewayConfigRoute
{
use route_t;
public function run(): void
{
$this->get('/edgegateway/config', fn() => $this->handleGetConfig(), [
'modules_shelly_config' => 'Get edge gateway config',
]);
$this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [
'modules_shelly_config' => 'Update edge gateway config',
]);
}
private function handleGetConfig(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
$response->success((new edgegateway())->config->getConfigRequest());
}
private function handlePostConfig(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config');
$response->success((new edgegateway())->config->postConfigRequest());
}
}
@@ -25,6 +25,21 @@ class edgeGatewaysRoute
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View department edge gateway detail',
]);
$this->get('/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
'modules_shelly_config' => 'View edge gateway task timeline',
]);
$this->get('/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
'modules_shelly_config' => 'View edge gateway logs',
]);
$this->get('/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
'modules_shelly_config' => 'View edge gateway statistics',
]);
$this->post('/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
'modules_shelly_config' => 'Create an edge gateway live stream session',
]);
$this->post('/edge-gateways/{id}/shell-sessions', fn() => $this->handleGatewayShellSessionCreate(), [
'modules_shelly_config' => 'Create an edge gateway shell session',
]);
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
]);
@@ -34,6 +49,9 @@ class edgeGatewaysRoute
$this->post('/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
'modules_shelly_config' => 'Queue an edge gateway operation',
]);
$this->post('/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
'modules_shelly_config' => 'Cancel an active edge gateway operation',
]);
$this->get('/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
'modules_shelly_config' => 'List edge gateway operation events',
]);
@@ -43,6 +61,9 @@ class edgeGatewaysRoute
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
]);
$this->get('/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
'modules_shelly_config' => 'View edge gateway installer session status',
]);
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
]);
@@ -57,12 +78,15 @@ class edgeGatewaysRoute
]);
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
$this->get('/edge-agent/artifacts/docker-compose.gateway.yml', fn() => $this->renderArtifact('docker-compose.gateway.yml'));
$this->get('/edge-agent/artifacts/Dockerfile.edge-agent', fn() => $this->renderArtifact('Dockerfile.edge-agent'));
$this->get('/edge-agent/artifacts/Dockerfile.lan-worker', fn() => $this->renderArtifact('Dockerfile.lan-worker'));
$this->get('/edge-agent/artifacts/Dockerfile.auto-updater', fn() => $this->renderArtifact('Dockerfile.auto-updater'));
$this->get('/edge-agent/artifacts/gateway-launcher.sh', fn() => $this->renderArtifact('gateway-launcher.sh'));
$this->get('/edge-agent/artifacts/truckwash-edge-gateway-stack.service', fn() => $this->renderArtifact('truckwash-edge-gateway-stack.service'));
$this->get('/edge-agent/artifacts/truckwash-edge-agent.service', fn() => $this->renderArtifact('truckwash-edge-agent.service'));
@@ -74,6 +98,18 @@ class edgeGatewaysRoute
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
$this->post('/edge-agent/internal/gateways/{id}/backlog', fn() => $this->handleBrokerGatewayBacklog());
$this->post('/edge-agent/internal/gateways/{id}/telemetry', fn() => $this->handleBrokerGatewayTelemetry());
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
}
private function handleListGateways(): void
@@ -87,9 +123,9 @@ class edgeGatewaysRoute
$this->requireDepartmentAccess($departmentId);
}
$gateways = $this->views()->listGateways($departmentId, $view !== 'summary');
$response->add_meta('fleet_usage', $this->views()->buildFleetUsageStatistics($departmentId, $gateways));
$response->success($gateways);
$payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary');
$response->add_meta('fleet_usage', $payload['fleet_usage']);
$response->success($payload['gateways']);
}
private function handleGatewayDetail(): void
@@ -99,6 +135,58 @@ class edgeGatewaysRoute
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
}
private function handleGatewayTasksPage(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
}
private function handleGatewayLogsPage(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
}
private function handleGatewayStatisticsPage(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
}
private function handleGatewayStreamSessionCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$payload = self::getParametersAsArray();
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
$response->success($this->manager()->createBrowserStreamSession($gatewayId, $this->actorUserId(), $scopes), 201);
}
private function handleGatewayShellSessionCreate(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$payload = self::getParametersAsArray();
$reason = isset($payload['reason']) ? (string)$payload['reason'] : '';
$cwd = isset($payload['cwd']) ? (string)$payload['cwd'] : null;
$cols = isset($payload['cols']) ? (int)$payload['cols'] : null;
$rows = isset($payload['rows']) ? (int)$payload['rows'] : null;
$response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201);
}
private function handleGatewayUpdate(): void
{
global /** @var response $response */ $response;
@@ -166,6 +254,29 @@ class edgeGatewaysRoute
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
}
private function handleGatewayOperationCancel(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, $this->actorUserId());
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
]);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayCredentialRotate(): void
{
global /** @var response $response */ $response;
@@ -195,6 +306,20 @@ class edgeGatewaysRoute
);
}
private function handleInstallTokenStatus(): void
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
$claimTokenId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($claimTokenId, 'id');
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
$this->requireDepartmentAccess((int)$status['department_id']);
unset($status['department_id']);
$response->success($status);
}
private function handleGatewayDiscovery(): void
{
global /** @var response $response */ $response;
@@ -265,6 +390,25 @@ class edgeGatewaysRoute
$response->success($this->install()->verifyInstallToken($token));
}
private function handleAgentInstallTokenStatus(): void
{
global /** @var response $response */ $response;
self::requireParameters(['token', 'status']);
$payload = self::getParametersAsArray();
$response->success($this->registry()->reportInstallTokenStatus(
(string)$payload['token'],
[
'status' => (string)$payload['status'],
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
]
));
}
private function renderArtifact(string $fileName): void
{
try {
@@ -417,6 +561,147 @@ class edgeGatewaysRoute
));
}
private function handleBrokerGatewayValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['token']);
$gatewayId = (int)$this->fromRoute('id');
$response->success($this->manager()->validateGatewayAgentForBroker($gatewayId, (string)self::getParameter('token')));
}
private function handleBrokerGatewayPresence(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->recordBrokerPresence(
$gatewayId,
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
isset($payload['reason']) ? (string)$payload['reason'] : null,
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
));
}
private function handleBrokerGatewayBacklog(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->buildBrokerBacklog(
$gatewayId,
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
));
}
private function handleBrokerGatewayTelemetry(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->recordTelemetryFromBroker($gatewayId, $payload));
}
private function handleBrokerOperationEvent(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->appendBrokerOperationEvent($gatewayId, $operationId, $payload));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleBrokerOperationComplete(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$payload = self::getParametersAsArray();
try {
$response->success($this->operations()->completeBrokerOperation($gatewayId, $operationId, $payload));
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleBrokerGatewayLogEntry(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['message']);
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->appendGatewayLogEntry(
$gatewayId,
(string)self::getParameter('message'),
isset($payload['level']) ? (string)$payload['level'] : 'INFO',
isset($payload['stream']) ? (string)$payload['stream'] : 'agent',
isset($payload['source']) ? (string)$payload['source'] : 'BROKER',
isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : []
));
}
private function handleBrokerBrowserStreamValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['token']);
$response->success($this->manager()->validateBrowserStreamToken((string)self::getParameter('token')));
}
private function handleBrokerShellSessionValidate(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['token']);
$response->success($this->manager()->validateShellSessionToken((string)self::getParameter('token')));
}
private function handleBrokerShellSessionOpened(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['token']);
$payload = self::getParametersAsArray();
$response->success($this->manager()->markShellSessionOpened(
(string)self::getParameter('token'),
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null
));
}
private function handleBrokerShellSessionClose(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
self::requireParameters(['token']);
$payload = self::getParametersAsArray();
$response->success($this->manager()->closeShellSessionByToken(
(string)self::getParameter('token'),
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
isset($payload['reason']) ? (string)$payload['reason'] : null
));
}
private function requireGatewayAccess(int $gatewayId): array
{
self::requireParameterIntPositive($gatewayId, 'id');
@@ -436,6 +721,15 @@ class edgeGatewaysRoute
return $token;
}
private function requireBrokerSecret(): void
{
global /** @var response $response */ $response;
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? ''));
if (!$this->manager()->validateBrokerSharedSecret($provided)) {
$response->error('Invalid edge broker secret', 403);
}
}
private function actorUserId(): ?int
{
$user = (new authentication())->get_user();
@@ -0,0 +1,430 @@
<?php
namespace routes;
use classes\authentication;
use classes\edge_gateway_manager;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\edgegateway;
use classes\response;
use Exception;
use objects\logs_o;
use traits\route_t;
class moduleEdgeGatewayRoute
{
use route_t;
public function run(): void
{
$this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'List edge gateway module fleet',
]);
$this->get('/modules/edge-gateways/workspace/departments', fn() => $this->handleDepartmentWorkspaceList(), [
'modules_shelly_config' => 'List department hardware workspaces',
]);
$this->get('/modules/edge-gateways/workspace/departments/{id}', fn() => $this->handleDepartmentWorkspaceDetail(), [
'modules_shelly_config' => 'View department hardware workspace detail',
]);
$this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View edge gateway module detail',
]);
$this->get('/modules/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
'modules_shelly_config' => 'View edge gateway module tasks',
]);
$this->get('/modules/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
'modules_shelly_config' => 'View edge gateway module logs',
]);
$this->get('/modules/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
'modules_shelly_config' => 'View edge gateway module statistics',
]);
$this->post('/modules/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
'modules_shelly_config' => 'Create an edge gateway module live stream session',
]);
$this->put('/modules/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
'modules_shelly_config' => 'Update edge gateway module metadata',
]);
$this->get('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
'modules_shelly_config' => 'List edge gateway module operations',
]);
$this->post('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
'modules_shelly_config' => 'Queue an edge gateway module operation',
]);
$this->post('/modules/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
'modules_shelly_config' => 'Cancel an edge gateway module operation',
]);
$this->get('/modules/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
'modules_shelly_config' => 'List edge gateway module operation events',
]);
$this->post('/modules/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
'modules_shelly_config' => 'Rotate edge gateway module credentials',
]);
$this->post('/modules/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
'modules_shelly_config' => 'Create an edge gateway module install token',
]);
$this->get('/modules/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
'modules_shelly_config' => 'View edge gateway module installer status',
]);
$this->post('/modules/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
'modules_shelly_config' => 'Queue discovery through the edge gateway module',
]);
$this->put('/modules/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
'modules_shelly_config' => 'Update edge gateway module relay bindings',
]);
$this->delete('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
'modules_shelly_config' => 'Delete an edge gateway module registration',
]);
$this->post('/modules/edge-gateways/departments/{id}/cutover', fn() => $this->handleDepartmentCutover(), [
'modules_shelly_config' => 'Update department cutover through the edge gateway module',
]);
}
private function handleListGateways(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
$view = trim((string)$this->fromQuery('view'));
if ($departmentId !== null && $departmentId > 0) {
$this->requireDepartmentAccess((string)$departmentId);
}
$payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary');
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LIST', 'Listed edge gateways');
$response->add_meta('fleet_usage', $payload['fleet_usage']);
$response->success($payload['gateways']);
}
private function handleGatewayDetail(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gateway = $this->requireGatewayAccess((int)$this->fromRoute('id'));
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_GET', 'Fetched edge gateway detail');
$response->success($gateway);
}
private function handleDepartmentWorkspaceList(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$summaries = $this->workspaces()->listDepartmentSummaries();
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_LIST', 'Listed department hardware workspace summaries');
$response->success($summaries);
}
private function handleDepartmentWorkspaceDetail(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_GET', 'Fetched department hardware workspace detail');
$response->success($this->workspaces()->getDepartmentWorkspace($departmentId));
}
private function handleGatewayTasksPage(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_TASKS_GET', 'Fetched edge gateway task timeline');
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
}
private function handleGatewayLogsPage(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LOGS_GET', 'Fetched edge gateway logs');
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
}
private function handleGatewayStatisticsPage(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STATISTICS_GET', 'Fetched edge gateway statistics');
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
}
private function handleGatewayStreamSessionCreate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$payload = self::getParametersAsArray();
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STREAM_SESSION_CREATE', 'Created edge gateway stream session');
$response->success($this->manager()->createBrowserStreamSession($gatewayId, (int)$user->id, $scopes), 201);
}
private function handleGatewayUpdate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
self::requireParameters(['label', 'is_primary']);
self::requireType(self::getParameter('label'), self::TYPE_STRING());
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$result = $this->registry()->updateGatewayMetadata($gatewayId, [
'label' => (string)self::getParameter('label'),
'is_primary' => (bool)self::getParameter('is_primary'),
], (int)$user->id);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_UPDATE', 'Updated edge gateway metadata');
$response->success($result);
}
private function handleGatewayOperationsList(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATIONS_LIST', 'Listed edge gateway operations');
$response->success($this->operations()->listOperations($gatewayId));
}
private function handleGatewayOperationCreate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
self::requireParameters(['type', 'request']);
self::requireType(self::getParameter('type'), self::TYPE_STRING());
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->queueOperation(
$gatewayId,
(string)self::getParameter('type'),
(array)self::getParameter('request'),
(int)$user->id
);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_QUEUE', 'Queued edge gateway operation');
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
], 201);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayOperationCancel(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
try {
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, (int)$user->id);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_CANCEL', 'Cancelled edge gateway operation');
$response->success([
'operation' => $operation,
'gateway' => $this->views()->getGateway($gatewayId),
]);
} catch (edge_gateway_operation_exception $exception) {
$response->error([
'message' => $exception->getMessage(),
'error_code' => $exception->errorCode,
], $exception->status);
}
}
private function handleGatewayOperationEvents(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$operationId = (int)$this->fromRoute('operationId');
self::requireParameterIntPositive($operationId, 'operationId');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_EVENTS_LIST', 'Listed edge gateway operation events');
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
}
private function handleGatewayCredentialRotate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_CREDENTIALS_ROTATE', 'Rotated edge gateway credentials');
$response->success($this->operations()->rotateCredentials($gatewayId, (int)$user->id));
}
private function handleInstallTokenCreate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
self::requireParameters(['department_id']);
$departmentId = (int)self::getParameter('department_id');
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_INSTALL_TOKEN_CREATE', 'Created edge gateway install token');
$response->success(
$this->registry()->createInstallToken(
$departmentId,
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
(int)$user->id
),
201
);
}
private function handleInstallTokenStatus(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$claimTokenId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($claimTokenId, 'id');
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
$departmentId = (int)($status['department_id'] ?? 0);
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add(
'modules_edgegateway',
'global',
1,
$user->id,
'MODULES_EDGEGATEWAY_INSTALL_TOKEN_STATUS',
'Viewed edge gateway installer status'
);
unset($status['department_id']);
$response->success($status);
}
private function handleGatewayDiscovery(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$this->operations()->queueDiscoveryOperation($gatewayId, (int)$user->id);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DISCOVERY_QUEUE', 'Queued edge gateway discovery');
$response->success($this->views()->getGateway($gatewayId));
}
private function handleBindingsUpdate(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
self::requireParameters(['bindings']);
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), (int)$user->id);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_BINDINGS_UPDATE', 'Updated edge gateway bindings');
$response->success($this->views()->getGateway($gatewayId));
}
private function handleGatewayDelete(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$gatewayId = (int)$this->fromRoute('id');
$this->requireGatewayAccess($gatewayId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DELETE', 'Deleted edge gateway');
$response->success($this->registry()->deleteGateway($gatewayId, (int)$user->id));
}
private function handleDepartmentCutover(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
self::requireParameters(['transport_mode']);
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DEPARTMENT_CUTOVER', 'Updated department gateway cutover');
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), (int)$user->id));
}
private function requireModuleOperator(): object
{
global /** @var response $response */ $response;
$this->requirePermission('modules_shelly_config');
try {
(new edgegateway())->requireModuleEnabled();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 409);
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
return $user;
}
private function requireGatewayAccess(int $gatewayId): array
{
global /** @var response $response */ $response;
$gateway = $this->views()->getGateway($gatewayId);
if (!isset($gateway['id'])) {
$response->error('Edge gateway not found', 404);
}
$departmentId = (int)($gateway['department_id'] ?? 0);
self::requireParameterIntPositive($departmentId, 'department_id');
$this->requireDepartmentAccess((string)$departmentId);
return $gateway;
}
private function views(): edge_gateway_view_service
{
return new edge_gateway_view_service();
}
private function registry(): edge_gateway_registry_service
{
return new edge_gateway_registry_service();
}
private function operations(): edge_gateway_operation_service
{
return new edge_gateway_operation_service();
}
private function manager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
private function workspaces(): edge_gateway_department_workspace_service
{
return new edge_gateway_department_workspace_service();
}
}
@@ -3,6 +3,7 @@
namespace objects;
use classes\department_gate_config;
use classes\edge_gateway_manager;
use classes\bird;
use classes\db;
use classes\object_property;
@@ -262,6 +263,11 @@ class department_gates_o extends db
return new slack();
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
/**
* @return array<int,array<string,mixed>>
*/
@@ -367,9 +373,25 @@ class department_gates_o extends db
$this->requireSelected();
$config = (array)$this->config->value();
if (!$this->matchesPhoneCallGateConfig($config)) {
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
if ($this->matchesPhoneCallGateConfig($config)) {
$this->openPhoneCallGate($config);
return;
}
if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') {
$this->openRelayBackedGate($config);
return;
}
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openPhoneCallGate(array $config): void
{
if (!isset($config['phone_number'])) {
throw new Exception('Phone number is required for PHONE_CALL gate type');
}
@@ -391,4 +413,30 @@ class department_gates_o extends db
throw new Exception('Failed to open gate relay via phone call', 0, $e);
}
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openRelayBackedGate(array $config): void
{
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($relayId === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
$departmentId = (int)$this->department->value();
if ($departmentId <= 0) {
throw new Exception('Gate department is invalid');
}
$pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1;
$manager = $this->resolveEdgeGatewayManager();
$manager->dispatchRelaySwitch($departmentId, $relayId, true);
if ($pulseSeconds > 0) {
usleep($pulseSeconds * 1000000);
$manager->dispatchRelaySwitch($departmentId, $relayId, false);
}
}
}
@@ -0,0 +1,61 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_log_entries_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $department_id;
public object_property $level;
public object_property $stream;
public object_property $source;
public object_property $message;
public object_property $context_json;
public object_property $created_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_log_entries');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
$this->stream = new object_property($this->table, $this->id, 'stream', 'string', false);
$this->source = new object_property($this->table, $this->id, 'source', 'string', false);
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(),
'level' => (string)$this->level->value(),
'stream' => (string)$this->stream->value(),
'source' => (string)$this->source->value(),
'message' => (string)$this->message->value(),
'context' => (array)($this->context_json->value() ?? []),
'created_at' => (string)$this->created_at->value(),
];
}
}
@@ -0,0 +1,98 @@
<?php
namespace objects;
use classes\db;
use classes\edge_gateway_schema_bootstrap;
use classes\object_property;
use traits\db_object_t;
class edge_gateway_shell_sessions_o extends db
{
use db_object_t;
public object_property $gateway_id;
public object_property $department_id;
public object_property $actor_user_id;
public object_property $session_token_hash;
public object_property $status;
public object_property $reason;
public object_property $connection_id;
public object_property $cwd;
public object_property $shell_command;
public object_property $shell_args_json;
public object_property $cols;
public object_property $rows;
public object_property $transcript;
public object_property $metadata_json;
public object_property $expires_at;
public object_property $approved_at;
public object_property $opened_at;
public object_property $closed_at;
public object_property $created_at;
public object_property $updated_at;
public object_property $deleted_at;
public function structure(): void
{
edge_gateway_schema_bootstrap::ensureTables();
$this->setTable('edge_gateway_shell_sessions');
}
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
$this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false);
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
$this->reason = new object_property($this->table, $this->id, 'reason', 'string', false);
$this->connection_id = new object_property($this->table, $this->id, 'connection_id', 'string', false);
$this->cwd = new object_property($this->table, $this->id, 'cwd', 'string', false);
$this->shell_command = new object_property($this->table, $this->id, 'shell_command', 'string', false);
$this->shell_args_json = new object_property($this->table, $this->id, 'shell_args_json', 'json', false);
$this->cols = new object_property($this->table, $this->id, 'cols', 'int', false);
$this->rows = new object_property($this->table, $this->id, 'terminal_rows', 'int', false);
$this->transcript = new object_property($this->table, $this->id, 'transcript', 'text', false);
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
}
public function objectChanged(): void
{
}
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'department_id' => (int)$this->department_id->value(),
'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(),
'status' => (string)$this->status->value(),
'reason' => $this->reason->value() === null ? null : (string)$this->reason->value(),
'connection_id' => $this->connection_id->value() === null ? null : (string)$this->connection_id->value(),
'cwd' => $this->cwd->value() === null ? null : (string)$this->cwd->value(),
'shell_command' => $this->shell_command->value() === null ? null : (string)$this->shell_command->value(),
'shell_args' => (array)($this->shell_args_json->value() ?? []),
'cols' => $this->cols->value() === null ? null : (int)$this->cols->value(),
'rows' => $this->rows->value() === null ? null : (int)$this->rows->value(),
'transcript' => $this->transcript->value() === null ? null : (string)$this->transcript->value(),
'metadata' => (array)($this->metadata_json->value() ?? []),
'expires_at' => $this->expires_at->value() === null ? null : (string)$this->expires_at->value(),
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
'created_at' => (string)$this->created_at->value(),
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
}
+141 -5
View File
@@ -4,6 +4,7 @@ namespace objects;
use classes\db;
use classes\object_property;
use Exception;
use traits\db_object_t;
class plate_scanners_o extends db
@@ -11,12 +12,16 @@ class plate_scanners_o extends db
use db_object_t;
public object_property $department_id;
public object_property $lane_id;
public object_property $name;
public object_property $notes;
public object_property $api_key;
private static bool $schemaInitialized = false;
public function structure(): void
{
self::ensureSchema();
$this->setTable('plate_scanners');
}
@@ -41,22 +46,25 @@ class plate_scanners_o extends db
public function getObjectProperties(): void
{
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int');
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int');
$this->name = new object_property($this->table, $this->id, 'name', 'string');
$this->notes = new object_property($this->table, $this->id, 'notes', 'string');
$this->api_key = new object_property($this->table, $this->id, 'api_key', 'string');
}
public function add(int $department_id, string $name, string $notes): void
public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void
{
global $db, $response;
try {
// Generate an API key
$api_key = bin2hex(random_bytes(32));
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
$laneValue = $lane_id === null ? 'NULL' : (string)$lane_id;
// Create a new record in the database
$sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')";
$sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')";
$db->query($sql);
// Get the id of the new record
@@ -69,7 +77,14 @@ class plate_scanners_o extends db
}
}
public function edit(int $id, int $department_id, string $name, string $notes): void
public function edit(
int $id,
int $department_id,
string $name,
string $notes,
?int $lane_id = null,
bool $laneIdProvided = false
): void
{
global $db, $response;
$this->id = $id;
@@ -77,8 +92,17 @@ class plate_scanners_o extends db
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
$setParts = [
"department_id = $department_id",
"name = '$name'",
"notes = '$notes'",
];
if ($laneIdProvided) {
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
$setParts[] = 'lane_id = ' . ($lane_id === null ? 'NULL' : (string)$lane_id);
}
// Update the record in the database
$sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id";
$sql = "UPDATE $this->table SET " . implode(', ', $setParts) . " WHERE id = $id";
$db->query($sql);
// Set the values of the object properties
@@ -102,4 +126,116 @@ class plate_scanners_o extends db
}
return $this;
}
}
/**
* @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string}
* @throws Exception
*/
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'department_id' => (int)$this->department_id->value(),
'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(),
'name' => (string)$this->name->value(),
'notes' => (string)$this->notes->value(),
'api_key' => (string)$this->api_key->value(),
];
}
/**
* @return array<int,plate_scanners_o>
* @throws Exception
*/
public function getDepartmentScanners(int $departmentId): array
{
$scanners = [];
$rows = self::getFieldsWhere([
'department_id' => $departmentId,
], ['id']);
foreach ($rows as $row) {
$scanner = (new plate_scanners_o())->select((int)$row['id']);
if ($scanner->exists()) {
$scanners[] = $scanner;
}
}
return $scanners;
}
/**
* @throws Exception
*/
public function rotateApiKey(int $id): array
{
$scanner = $this->select($id);
if (!$scanner->exists()) {
throw new Exception('Number plate scanner not found');
}
$newApiKey = bin2hex(random_bytes(32));
$scanner->api_key->set($newApiKey);
return $scanner->asArray();
}
private function normalizeLaneId(int $departmentId, ?int $laneId): ?int
{
if ($laneId === null || $laneId <= 0) {
return null;
}
$lane = (new department_lanes_o())->select($laneId);
if (!$lane->exists()) {
throw new Exception('Department lane not found');
}
if ((int)$lane->department->value() !== $departmentId) {
throw new Exception('The lane does not belong to the number plate scanner department');
}
return (int)$lane->id;
}
private static function ensureSchema(): void
{
if (self::$schemaInitialized) {
return;
}
global $db;
if (!self::tableHasColumn('plate_scanners', 'lane_id')) {
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
}
self::$schemaInitialized = true;
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -0,0 +1,11 @@
ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
rm -rf /var/lib/apt/lists/*
COPY auto-updater.php /usr/local/bin/auto-updater.php
ENTRYPOINT ["php", "/usr/local/bin/auto-updater.php"]
@@ -3,16 +3,6 @@ FROM ${BASE_IMAGE}
WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libcurl4-openssl-dev \
libsqlite3-dev; \
docker-php-ext-install curl sqlite3; \
rm -rf /var/lib/apt/lists/*
COPY agent.php /opt/truckwash-edge-agent/agent.php
ENTRYPOINT ["php", "/opt/truckwash-edge-agent/agent.php"]
@@ -3,15 +3,6 @@ FROM ${BASE_IMAGE}
WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
libcurl4-openssl-dev; \
docker-php-ext-install curl; \
rm -rf /var/lib/apt/lists/*
COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php
CMD ["php", "-S", "0.0.0.0:8090", "/opt/truckwash-edge-agent/lan-worker.php"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This updater must run from the CLI.\n");
exit(1);
}
$installDir = rtrim((string)(getenv('TRUCKWASH_INSTALL_DIR') ?: '/opt/truckwash-edge-agent'), DIRECTORY_SEPARATOR);
$runtimeDir = $installDir . DIRECTORY_SEPARATOR . 'runtime';
$launcherPath = $installDir . DIRECTORY_SEPARATOR . 'gateway-launcher.sh';
$stagedUpdatePath = $runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$heartbeatPath = $runtimeDir . DIRECTORY_SEPARATOR . 'auto-updater-heartbeat.json';
$intervalSeconds = max(15, (int)(getenv('AUTO_UPDATER_INTERVAL_SECONDS') ?: 30));
if (!is_dir($runtimeDir)) {
@mkdir($runtimeDir, 0777, true);
}
$writeHeartbeat = static function (array $payload) use ($heartbeatPath): void {
$payload['updated_at'] = date(DATE_ATOM);
file_put_contents($heartbeatPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
};
$writeHeartbeat([
'status' => 'starting',
'interval_seconds' => $intervalSeconds,
]);
while (true) {
$stagedUpdatePresent = is_file($stagedUpdatePath);
$writeHeartbeat([
'status' => $stagedUpdatePresent ? 'waiting_for_window' : 'idle',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => $stagedUpdatePresent,
]);
if ($stagedUpdatePresent) {
$writeHeartbeat([
'status' => 'reconciling',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => true,
]);
$output = [];
$exitCode = 0;
exec('/bin/bash ' . escapeshellarg($launcherPath) . ' reconcile 2>&1', $output, $exitCode);
$writeHeartbeat([
'status' => $exitCode === 0 ? 'idle' : 'error',
'interval_seconds' => $intervalSeconds,
'staged_update_present' => is_file($stagedUpdatePath),
'last_exit_code' => $exitCode,
'last_output' => implode("\n", array_slice($output, -40)),
'last_reconciled_at' => date(DATE_ATOM),
]);
}
sleep($intervalSeconds);
}
@@ -1,4 +1,42 @@
version: "2.4"
services:
redis:
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
container_name: truckwash-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./runtime/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
mariadb:
image: ${MARIADB_BASE_IMAGE:-mariadb:11}
container_name: truckwash-mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: truckwash_edge
MARIADB_USER: truckwash_edge
MARIADB_PASSWORD: truckwash_edge
MARIADB_ROOT_PASSWORD: truckwash_edge_root
volumes:
- ./runtime/mariadb:/var/lib/mysql
minio:
image: ${MINIO_BASE_IMAGE:-minio/minio:latest}
container_name: truckwash-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: truckwashminio
MINIO_ROOT_PASSWORD: truckwash_edge_storage
volumes:
- ./runtime/minio:/data
lan-worker:
build:
context: .
@@ -9,10 +47,23 @@ services:
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
depends_on:
redis:
condition: service_healthy
mariadb:
condition: service_started
minio:
condition: service_started
volumes:
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8090/health"]
test:
[
"CMD",
"php",
"-r",
"$$json=@file_get_contents('http://127.0.0.1:8090/health'); if ($$json===false) exit(1); $$data=json_decode($$json,true); exit((($$data['status'] ?? '') === 'healthy') ? 0 : 1);",
]
interval: 30s
timeout: 5s
retries: 3
@@ -26,13 +77,45 @@ services:
container_name: truckwash-edge-agent
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
mariadb:
condition: service_started
minio:
condition: service_started
lan-worker:
condition: service_healthy
volumes:
- ./config.json:/config/config.json
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
test: ["CMD-SHELL", "test -f /opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt"]
test: ["CMD-SHELL", "kill -0 1"]
interval: 30s
timeout: 5s
retries: 3
auto-updater:
build:
context: .
dockerfile: Dockerfile.auto-updater
args:
BASE_IMAGE: ${AUTO_UPDATER_BASE_IMAGE:-php:8.2-cli-bookworm}
container_name: truckwash-auto-updater
restart: unless-stopped
depends_on:
edge-agent:
condition: service_started
environment:
AUTO_UPDATER_INTERVAL_SECONDS: 30
volumes:
- .:/opt/truckwash-edge-agent
- /var/run/docker.sock:/var/run/docker.sock
healthcheck:
test:
[
"CMD-SHELL",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($$path)) { exit(1); } exit((time() - filemtime($$path)) <= 90 ? 0 : 1);'",
]
interval: 30s
timeout: 5s
retries: 3
@@ -8,6 +8,7 @@ COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
RUNTIME_DIR="$INSTALL_DIR/runtime"
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"
log() {
printf '[gateway-launcher] %s\n' "$1"
@@ -28,6 +29,17 @@ compose_cmd() {
return 1
}
print_compose_diagnostics() {
log "docker ps --format '{{.Names}} {{.Status}}'"
docker ps --format '{{.Names}} {{.Status}}' || true
log "compose ps"
compose_cmd -f "$COMPOSE_FILE" ps || true
log "compose logs --tail=80"
compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true
}
config_value() {
local key="$1"
local fallback="${2:-}"
@@ -94,10 +106,26 @@ EOF_JSON
apply_stack() {
local edge_base_image
local worker_base_image
local auto_updater_base_image
local redis_base_image
local mariadb_base_image
local minio_base_image
local compose_project_name
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
auto_updater_base_image="$(config_value autoUpdaterBaseImage 'php:8.2-cli-bookworm')"
redis_base_image="$(config_value redisBaseImage 'redis:7-alpine')"
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
cd "$INSTALL_DIR"
EDGE_AGENT_BASE_IMAGE="$edge_base_image" LAN_WORKER_BASE_IMAGE="$worker_base_image" \
COMPOSE_PROJECT_NAME="$compose_project_name" \
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
LAN_WORKER_BASE_IMAGE="$worker_base_image" \
AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image" \
REDIS_BASE_IMAGE="$redis_base_image" \
MARIADB_BASE_IMAGE="$mariadb_base_image" \
MINIO_BASE_IMAGE="$minio_base_image" \
compose_cmd -f "$COMPOSE_FILE" up -d --build
}
@@ -107,9 +135,11 @@ rollback_stack() {
for file in \
agent.php \
lan-worker.php \
auto-updater.php \
docker-compose.gateway.yml \
Dockerfile.edge-agent \
Dockerfile.lan-worker \
Dockerfile.auto-updater \
gateway-launcher.sh \
truckwash-edge-gateway-stack.service \
truckwash-edge-agent.service; do
@@ -117,7 +147,10 @@ rollback_stack() {
mv -f "$INSTALL_DIR/$file.bak" "$INSTALL_DIR/$file"
fi
done
apply_stack
if ! apply_stack; then
write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"
return 1
fi
write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"
if [ -f "$STAGED_UPDATE_PATH" ]; then
php -r '
@@ -133,18 +166,53 @@ rollback_stack() {
fi
}
container_is_healthy() {
local container_name="$1"
local health
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || echo missing)"
[ "$health" = "healthy" ] || [ "$health" = "running" ]
}
healthcheck_stack() {
curl -fsS http://127.0.0.1:8090/health >/dev/null
container_is_healthy truckwash-redis &&
container_is_healthy truckwash-mariadb &&
container_is_healthy truckwash-minio &&
container_is_healthy truckwash-lan-worker &&
container_is_healthy truckwash-edge-agent &&
container_is_healthy truckwash-auto-updater
}
wait_for_stack_health() {
local timeout_seconds="${1:-$STACK_HEALTHCHECK_TIMEOUT_SECONDS}"
local elapsed=0
while [ "$elapsed" -lt "$timeout_seconds" ]; do
if healthcheck_stack; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
reconcile_stack() {
local installed_version
installed_version="$(config_value installedVersion '')"
ensure_dirs
log "Reconciling compose stack"
apply_stack
sleep 5
if ! apply_stack; then
log "Compose rollout failed during build/startup"
print_compose_diagnostics
write_rollback_status "FAILED" "compose_up_failed" "$installed_version"
return 1
fi
if ! healthcheck_stack; then
if ! wait_for_stack_health "$STACK_HEALTHCHECK_TIMEOUT_SECONDS"; then
log "Healthcheck failed after compose rollout; reverting to previous artifacts"
print_compose_diagnostics
rollback_stack
return 1
fi
@@ -10,7 +10,7 @@ WorkingDirectory=/opt/truckwash-edge-agent
ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up
ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile
ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down
TimeoutStartSec=300
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
@@ -69,6 +69,17 @@ class machineButtonPressRoute
return (int)$lane->id;
}
if ($plateScanner->lane_id->value() !== null) {
$lane = (new department_lanes_o())->select((int)$plateScanner->lane_id->value());
if (!$lane->exists()) {
$response->error('The default lane configured for the plate scanner no longer exists', 404);
}
if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) {
$response->error('The default lane does not belong to the plate scanner department', 403);
}
return (int)$lane->id;
}
$lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value());
if (count($lanes) === 1) {
return (int)$lanes[0]->id;
@@ -34,7 +34,11 @@ class plateScannersRoute
'name',
'notes'
])
->listObjectsWithPaginationIfSet()
->listObjectsWithPaginationIfSet(
static function (array $scanner): array {
return (new plate_scanners_o())->select((int)$scanner['id'])->asArray();
}
)
);
} else {
// Log the incident
@@ -68,12 +72,19 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneId = array_key_exists('lane_id', $data) && $data['lane_id'] !== null
? (int)$data['lane_id']
: null;
// Add the number plate scanner
(new plate_scanners_o())->add($data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->add((int)$data['department_id'], (string)$data['name'], (string)$data['notes'], $laneId);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner added']);
$response->success([
'message' => 'Number plate scanner added',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -109,12 +120,25 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneIdProvided = array_key_exists('lane_id', $data);
$laneId = $laneIdProvided && $data['lane_id'] !== null ? (int)$data['lane_id'] : null;
// Edit the number plate scanner
(new plate_scanners_o())->edit($data['id'], $data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->edit(
(int)$data['id'],
(int)$data['department_id'],
(string)$data['name'],
(string)$data['notes'],
$laneId,
$laneIdProvided
);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner edited']);
$response->success([
'message' => 'Number plate scanner edited',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -127,6 +151,35 @@ class plateScannersRoute
]
);
$this->post('/numberplatescanners/{id}/rotate-key', function () {
global $response;
$this->requirePermission('edit_number_plate_scanner');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$scannerId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($scannerId, 'id');
$scanner = (new plate_scanners_o())->select($scannerId);
if (!$scanner->exists()) {
$response->error('Number plate scanner not found', 404);
}
self::requireDepartmentAccess((int)$scanner->department_id->value());
$rotatedScanner = (new plate_scanners_o())->rotateApiKey($scannerId);
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'Successfully rotated a number plate scanner API key');
$response->success([
'message' => 'Number plate scanner API key rotated',
'scanner' => $rotatedScanner,
'api_key' => (string)$rotatedScanner['api_key'],
]);
}, [
'edit_number_plate_scanner' => 'Rotate a number plate scanner API key',
]);
self::get('/department/numberplatescanners', function () {
// Require the user to be logged in
global $response;
@@ -152,12 +205,14 @@ class plateScannersRoute
'department_id' => (int)self::getParameter('id')
], [
'id',
'lane_id',
'name',
'notes'
]);
// Parse the result
foreach ( $result as $key => $value ) {
$result[$key]['id'] = (int)$value['id'];
$result[$key]['lane_id'] = $value['lane_id'] === null ? null : (int)$value['lane_id'];
}
// Return the list of plate scanners
$response->success(
@@ -176,4 +231,4 @@ class plateScannersRoute
]
);
}
}
}
+19 -1
View File
@@ -10,7 +10,19 @@ function app_path(string $relative = ''): string
return WD;
}
return WD . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
$normalized = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
if (str_starts_with($normalized, 'classes' . DIRECTORY_SEPARATOR . 'edge_gateway_')) {
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . basename($normalized);
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php') {
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php';
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php') {
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php';
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php') {
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php';
}
return WD . DIRECTORY_SEPARATOR . $normalized;
}
function app_require(string $relative): void
@@ -36,6 +48,12 @@ spl_autoload_register(function (string $class): void {
$candidates = [];
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
}
if ($top === 'routes' && in_array($relative, ['edgeGatewaysRoute', 'moduleEdgeGatewayRoute', 'edgeGatewayConfigRoute'], true)) {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . $relative;
}
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
} elseif ($top === 'modules') {
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
@@ -0,0 +1,79 @@
<?php
app_require('classes/edge_gateway_manager.php');
app_require('classes/object_property.php');
app_require('objects/department_gates_o.php');
use classes\edge_gateway_manager;
use classes\object_property;
use objects\department_gates_o;
final class DepartmentGatesRelayManagerFake extends edge_gateway_manager
{
public array $switchCalls = [];
public function __construct()
{
}
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
{
$this->switchCalls[] = [
'department_id' => $departmentId,
'relay_id' => $logicalRelayId,
'on' => $on,
];
return [
'relay_id' => $logicalRelayId,
'online' => true,
'on' => $on,
];
}
}
final class DepartmentGatesRelayOpenHarness extends department_gates_o
{
public function __construct(
array $config,
int $departmentId,
private readonly DepartmentGatesRelayManagerFake $manager,
) {
$this->id = 1001;
$departmentProperty = new object_property('department_gates', -1, 'department', 'int');
$departmentProperty->set($departmentId);
$this->department = $departmentProperty;
$configProperty = new object_property('department_gates', -1, 'config', 'json');
$configProperty->set($config);
$this->config = $configProperty;
}
public function requireSelected(): void
{
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return $this->manager;
}
}
it('dispatches relay-backed gates through the edge gateway relay manager', function (): void {
$manager = new DepartmentGatesRelayManagerFake();
$gate = new DepartmentGatesRelayOpenHarness([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
], 17, $manager);
$gate->openGate();
expect($manager->switchCalls)->toBe([
[
'department_id' => 17,
'relay_id' => 'ENTRY-GATE-1',
'on' => true,
],
]);
});
@@ -0,0 +1,30 @@
<?php
app_require('classes/department_gate_config.php');
use classes\department_gate_config;
it('validates relay gate configs and keeps relay-specific fields in the payload', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
$config->validate();
expect($config->toArray())->toMatchArray([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
});
it('rejects relay gate configs without a logical relay id', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
]);
expect(fn() => $config->validate())
->toThrow(Exception::class, 'relay_id is required for RELAY gate type');
});
@@ -0,0 +1,17 @@
<?php
it('defines the department hardware workspace service payload surface', function (): void {
$service = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php'));
expect($service)->not->toBeFalse();
expect($service)->toContain('class edge_gateway_department_workspace_service');
expect($service)->toContain("'summary' => \$summary");
expect($service)->toContain("'gateways' => \$includeGateways ? \$gateways : []");
expect($service)->toContain("'lanes' => \$lanes");
expect($service)->toContain("'self_serve' => \$selfServe");
expect($service)->toContain("'gates' => \$gates");
expect($service)->toContain("'scanners' => \$scanners");
expect($service)->toContain("'issues' => \$issues");
expect($service)->toContain("'actions' => \$actions");
expect($service)->toContain("'consumer_contexts'");
});
@@ -89,3 +89,62 @@ it('summarizes fleet usage statistics for the dashboard landing view', function
'disk_usage_pct_avg' => 67,
]);
});
it('derives fleet usage directly from cached gateway row summaries', function (): void {
$summary = edge_gateway_manager::summarizeFleetUsageFromGatewayRows([
[
'id' => 701,
'department_id' => 1,
'status' => edge_gateway_manager::STATUS_ONLINE,
'version_drift' => ['is_drifted' => true],
'channel_status' => ['broker' => ['connected' => true]],
'active_operation' => ['id' => 91, 'type' => 'DISCOVERY'],
'recent_operations_summary' => ['pending' => 1, 'in_progress' => 1],
'backlog_depth' => ['operations' => 2, 'commands' => 3],
'metadata' => [
'system_metrics' => [
'latency_ms' => 184,
'cpu_usage_pct' => 27,
'memory_usage_pct' => 61,
'disk_usage_pct' => 58,
],
],
'inventory_summary' => ['total' => 2, 'online' => 1, 'offline' => 1],
'binding_summary' => ['total' => 3, 'fallback_overrides' => 2],
'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 1],
],
[
'id' => 702,
'department_id' => 2,
'status' => edge_gateway_manager::STATUS_OFFLINE,
'version_drift' => ['is_drifted' => false],
'channel_status' => ['broker' => ['connected' => false]],
'active_operation' => null,
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
'backlog_depth' => ['operations' => 0, 'commands' => 1],
'metadata' => [
'system_metrics' => [
'latency_ms' => 412,
'cpu_usage_pct' => 9,
'memory_usage_pct' => 42,
'disk_usage_pct' => 76,
],
],
'inventory_summary' => ['total' => 1, 'online' => 1, 'offline' => 0],
'binding_summary' => ['total' => 0, 'fallback_overrides' => 0],
'fallback_summary' => ['cloud_only_relays' => 0, 'local_only_relays' => 0],
],
]);
expect($summary['inventory'])->toBe([
'total' => 3,
'online' => 2,
'offline' => 1,
]);
expect($summary['bindings'])->toBe([
'total' => 3,
'fallback_overrides' => 2,
'cloud_only' => 1,
'local_only' => 1,
]);
});
@@ -0,0 +1,94 @@
<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('caps install-session diagnostics and events while preserving the first start timestamp', function (): void {
$session = [];
for ($index = 1; $index <= 14; $index += 1) {
$session = edge_gateway_manager::mergeInstallSessionUpdate($session, [
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
'step' => 'STEP_' . $index,
'message' => 'Installer phase ' . $index,
], strtotime('2026-04-08 10:00:' . str_pad((string)$index, 2, '0', STR_PAD_LEFT)));
}
$failed = edge_gateway_manager::mergeInstallSessionUpdate($session, [
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
'step' => 'START_STACK',
'message' => 'Compose rollout failed during startup.',
'diagnostics' => array_map(
static fn(int $index): array => [
'name' => 'Diagnostic ' . $index,
'output' => 'Output ' . $index,
],
range(1, 8)
),
], strtotime('2026-04-08 10:01:30'));
expect($failed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
expect($failed['step'])->toBe('START_STACK');
expect($failed['message'])->toBe('Compose rollout failed during startup.');
expect($failed['started_at'])->toBe('2026-04-08 10:00:01');
expect($failed['updated_at'])->toBe('2026-04-08 10:01:30');
expect($failed['last_error'])->toBe('Compose rollout failed during startup.');
expect($failed['diagnostics'])->toHaveCount(6);
expect($failed['diagnostics'][0]['name'])->toBe('Diagnostic 3');
expect($failed['diagnostics'][5]['name'])->toBe('Diagnostic 8');
expect($failed['events'])->toHaveCount(12);
expect($failed['events'][11]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
expect($failed['events'][11]['step'])->toBe('START_STACK');
});
it('clears terminal failure details after a successful claim update', function (): void {
$claimed = edge_gateway_manager::mergeInstallSessionUpdate([
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
'step' => 'START_STACK',
'message' => 'Compose rollout failed during startup.',
'started_at' => '2026-04-08 10:00:01',
'updated_at' => '2026-04-08 10:01:30',
'last_error' => 'Compose rollout failed during startup.',
'diagnostics' => [
['name' => 'systemctl status', 'output' => 'failed'],
],
'events' => [
[
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
'step' => 'START_STACK',
'message' => 'Compose rollout failed during startup.',
'at' => '2026-04-08 10:01:30',
],
],
], [
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
'step' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
'message' => 'Gateway claimed successfully.',
'gateway_id' => 703,
], strtotime('2026-04-08 10:02:00'));
expect($claimed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
expect($claimed['gateway_id'])->toBe(703);
expect($claimed['last_error'])->toBeNull();
expect($claimed['diagnostics'])->toBe([]);
expect($claimed['events'])->toHaveCount(2);
expect($claimed['events'][1]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
});
it('marks expired non-terminal install sessions as terminal when read back', function (): void {
$normalized = edge_gateway_manager::normalizeInstallSessionRecord([
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
'step' => 'WAIT_FOR_CLAIM',
'message' => 'Installer is waiting for the gateway heartbeat and claim.',
'started_at' => '2026-04-08 10:00:01',
'updated_at' => '2026-04-08 10:01:30',
], '2026-04-08 10:01:00', strtotime('2026-04-08 10:02:00'));
expect($normalized['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_EXPIRED);
expect($normalized['step'])->toBe('WAIT_FOR_CLAIM');
expect($normalized['updated_at'])->toBe('2026-04-08 10:01:30');
expect($normalized['message'])->toBe('Installer is waiting for the gateway heartbeat and claim.');
expect($normalized['last_error'])->toBe('Install token expired.');
expect($normalized['terminal'])->toBeTrue();
});
@@ -16,8 +16,12 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($operationServiceSource)->toContain('public function queueOperation');
expect($operationServiceSource)->toContain('public function cancelOperation');
expect($operationServiceSource)->toContain('public function claimNextOperation');
expect($operationServiceSource)->toContain('public function completeAgentOperation');
expect($operationServiceSource)->toContain("public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';");
expect($operationServiceSource)->toContain("public const STATUS_CANCELLED = 'CANCELLED';");
expect($operationServiceSource)->toContain("public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';");
expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;');
expect($operationServiceSource)->toContain('private function refreshOperationLease');
expect($operationServiceSource)->toContain('agent_instance_id');
@@ -41,6 +45,7 @@ it('loads relay command helpers on the manager and gateway operations on the ded
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue();
expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue();
expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue();
@@ -43,21 +43,29 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
expect($script)->toContain('report_install_status() {');
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
expect($script)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
expect($script)->toContain('report_install_status "CLAIMED" "CLAIMED"');
expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}');
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
expect($script)->toContain('Response body preview (first 400 bytes):');
expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
expect($script)->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
expect($script)->not->toContain('"brokerUrl"');
});
});
@@ -86,6 +94,6 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->not->toContain('"brokerUrl"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test:4300"');
});
});
@@ -0,0 +1,45 @@
<?php
it('registers module-scoped edge gateway operator routes', function (): void {
$route = file_get_contents(app_path('modules/edgegateway/routes/moduleEdgeGatewayRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/modules/edge-gateways'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments/{id}'");
expect($route)->toContain("'/modules/edge-gateways/{id}'");
expect($route)->toContain("'/modules/edge-gateways/install-token'");
expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'");
expect($route)->toContain("'/modules/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/modules/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/modules/edge-gateways/{id}/operations'");
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/cancel'");
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/modules/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/modules/edge-gateways/departments/{id}/cutover'");
expect($route)->toContain('requireModuleEnabled()');
expect($route)->not->toContain("'/modules/edge-gateways/{id}/shell-sessions'");
});
it('registers edge gateway config endpoints from the module route directory', function (): void {
$route = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewayConfigRoute.php'));
$legacyRoute = file_get_contents(app_path('routes/moduleConfigRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/edgegateway/config'");
expect($route)->toContain('new edgegateway()');
expect($legacyRoute)->not->toContain("'/edgegateway/config'");
});
it('keeps only the module facade in the global classes directory and conditionally loads module routes', function (): void {
$classes = glob(app_path('classes/*.php')) ?: [];
$edgeGatewayClasses = array_values(array_filter($classes, static function (string $path): bool {
$name = basename($path);
return str_contains($name, 'edgegateway') || str_contains($name, 'edge_gateway');
}));
$index = file_get_contents(app_path('index.php'));
expect($edgeGatewayClasses)->toEqual([app_path('classes/edgegateway.php')]);
expect($index)->toContain("\$routes_path = \$modules_path . DIRECTORY_SEPARATOR . \$module_dir . DIRECTORY_SEPARATOR . 'routes'");
expect($index)->toContain("method_exists(\$module, 'isEnabled') && !\$module->isEnabled()");
});
@@ -7,14 +7,21 @@ it('registers the v2 operator-facing edge gateway routes', function (): void {
expect($route)->toContain("'/edge-gateways'");
expect($route)->toContain("'/edge-gateways/{id}'");
expect($route)->toContain("'/edge-gateways/install-token'");
expect($route)->toContain("'/edge-gateways/install-token/{id}/status'");
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
expect($route)->toContain("'/edge-gateways/{id}/operations'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
expect($route)->toContain("'/edge-gateways/{id}/tasks'");
expect($route)->toContain("'/edge-gateways/{id}/logs'");
expect($route)->toContain("'/edge-gateways/{id}/statistics'");
expect($route)->toContain("'/edge-gateways/{id}/stream-session'");
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
expect($route)->toContain("add_meta('fleet_usage'");
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($route)->toContain('listGatewaysWithFleetUsage(');
expect($route)->not->toContain('private function requirePermission');
expect($route)->not->toContain('private function requireDepartmentAccess');
});
@@ -23,12 +30,15 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->toContain("'/edge-agent/install-token/verify'");
expect($route)->toContain("'/edge-agent/install-token/status'");
expect($route)->toContain("'/edge-agent/install.sh'");
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
expect($route)->toContain("'/edge-agent/artifacts/auto-updater.php'");
expect($route)->toContain("'/edge-agent/artifacts/docker-compose.gateway.yml'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.edge-agent'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.lan-worker'");
expect($route)->toContain("'/edge-agent/artifacts/Dockerfile.auto-updater'");
expect($route)->toContain("'/edge-agent/artifacts/gateway-launcher.sh'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-gateway-stack.service'");
expect($route)->toContain("'/edge-agent/artifacts/truckwash-edge-agent.service'");
@@ -40,6 +50,12 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/backlog'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/telemetry'");
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/logs'");
expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
expect($route)->toContain('echo $exception->getMessage()');
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
@@ -12,7 +12,8 @@ it('defines the v2 edge gateway schema bootstrap tables', function (): void {
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_log_entries');
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
});
@@ -40,7 +41,10 @@ it('stores operation metadata and event timelines for management workflows', fun
expect($bootstrapContent)->toContain('SET type = operation_type');
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')");
expect($bootstrapContent)->toContain('summary_json JSON NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL');
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
expect($bootstrapContent)->toContain('session_token_hash CHAR(64) NOT NULL');
expect($bootstrapContent)->toContain('terminal_rows INT NULL');
expect($bootstrapContent)->toContain("renameColumnIfPresent('edge_gateway_shell_sessions', 'rows', 'terminal_rows', 'INT NULL', 'cols')");
});
@@ -4,44 +4,131 @@ it('builds the installer around the compose stack artifacts and management polli
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$serviceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-agent.service'));
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
$composeSource = file_get_contents(app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'));
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
$autoUpdaterDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.auto-updater'));
expect($managerSource)->not->toBeFalse();
expect($launcherSource)->not->toBeFalse();
expect($composeSource)->not->toBeFalse();
expect($edgeDockerfileSource)->not->toBeFalse();
expect($workerDockerfileSource)->not->toBeFalse();
expect($autoUpdaterSource)->not->toBeFalse();
expect($autoUpdaterDockerfileSource)->not->toBeFalse();
expect($managerSource)->toContain("'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS");
expect($managerSource)->toContain('fetch_http "Verify install token" "__VERIFY_URL__"');
expect($managerSource)->toContain('fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"');
expect($managerSource)->toContain('fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"');
expect($managerSource)->toContain('fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"');
expect($managerSource)->toContain('fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"');
expect($managerSource)->toContain('fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"');
expect($managerSource)->toContain('fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
expect($managerSource)->toContain('log_error "Request: GET ${url}"');
expect($managerSource)->toContain('log_error "Response body preview (first 400 bytes):"');
expect($managerSource)->toContain('apt-get install -y curl ca-certificates docker.io docker-compose-plugin php-cli php-curl php-mbstring php-sqlite3');
expect($managerSource)->toContain('run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3');
expect($managerSource)->toContain('run_step "Installing Docker Compose runtime" install_compose_runtime');
expect($managerSource)->toContain('apt-get install -y docker-compose-plugin');
expect($managerSource)->toContain('apt-get install -y docker-compose');
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
expect($managerSource)->toContain('report_install_status() {');
expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"');
expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"');
expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
expect($managerSource)->not->toContain('agent.mjs');
expect($managerSource)->not->toContain('"brokerUrl"');
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
expect($stackServiceSource)->toContain('TimeoutStartSec=900');
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"');
expect($launcherSource)->toContain('wait_for_stack_health');
expect($launcherSource)->toContain('container_is_healthy truckwash-auto-updater');
expect($launcherSource)->toContain('compose_project_name="$(config_value composeProjectName \'truckwash-edge-gateway\')"');
expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name"');
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true');
expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"');
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
expect($composeSource)->toContain('version: "2.4"');
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain("test: [\"CMD-SHELL\", \"kill -0 1\"]");
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
expect($composeSource)->not->toContain("curl\", \"-fsS\", \"http://127.0.0.1:9000/minio/health/live");
expect($composeSource)->not->toContain('mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent');
expect($composeSource)->toContain('container_name: truckwash-redis');
expect($composeSource)->toContain('container_name: truckwash-mariadb');
expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
expect($workerDockerfileSource)->not->toContain('docker-php-ext-install');
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;');
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
});
it('exposes update payload, credential rotation, and operation endpoints without shell transport wiring', function (): void {
it('exposes update payload, credential rotation, cancel endpoints, and operation endpoints without shell transport wiring', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$routeSource = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
expect($managerSource)->toContain('public function rotateGatewayCredentials');
expect($managerSource)->toContain("'autoUpdaterArtifactUrl' => \$this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT)");
expect($managerSource)->toContain("'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE");
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
expect($routeSource)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($agentSource)->toContain("/operations/next");
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
expect($agentSource)->toContain('final class OperationAbortException extends RuntimeException');
expect($agentSource)->toContain('private BrokerWebSocketClient $brokerClient;');
expect($agentSource)->toContain('private AgentShellBridge $shellBridge;');
expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool');
expect($agentSource)->toContain("'type' => 'TELEMETRY'");
expect($agentSource)->toContain("'type' => 'TASK_EVENT'");
expect($agentSource)->toContain("'type' => 'TASK_RESULT'");
expect($agentSource)->toContain("'type' => 'LOG_FRAME'");
expect($agentSource)->toContain('private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array');
expect($agentSource)->toContain('throw new OperationAbortException(\'Operation cancelled by operator\', true);');
expect($agentSource)->toContain('private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;');
expect($agentSource)->toContain('if ($this->resumePendingOperationCompletion()) {');
expect($agentSource)->toContain('$this->finalizeOperationCompletion(');
expect($agentSource)->toContain('private function finalizeOperationCompletion(');
expect($agentSource)->toContain('private function resumePendingOperationCompletion(): bool');
expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool');
expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';");
expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';");
expect($agentSource)->toContain("? self::OPERATION_COMPLETE_TIMEOUT_SECONDS");
expect($agentSource)->toContain("unset(\$state['completion']);");
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
expect($agentSource)->toContain('], $this->pollRequestTimeoutSeconds($waitSeconds));');
expect($agentSource)->toContain('} catch (HttpRequestTimeoutException) {');
expect($agentSource)->toContain('private function pollRequestTimeoutSeconds(int $waitSeconds): int');
expect($agentSource)->toContain('last-heartbeat-ok.txt');
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
});
@@ -0,0 +1,218 @@
<?php
app_require('classes/edge_gateway_manager.php');
app_require('classes/edge_gateway_view_cache.php');
use classes\edge_gateway_manager;
use classes\edge_gateway_view_cache;
if (!class_exists('EdgeGatewayViewCacheRedisFake')) {
class EdgeGatewayViewCacheRedisFake
{
/** @var array<string,string> */
public array $store = [];
public function get(string $key): ?string
{
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key) === 1) {
unset($this->store[$key]);
}
}
}
}
}
beforeEach(function (): void {
$this->oldTtl = getenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
$this->redis = new EdgeGatewayViewCacheRedisFake();
edge_gateway_view_cache::setAdapterForTests($this->redis);
});
afterEach(function (): void {
edge_gateway_view_cache::setAdapterForTests(null);
if ($this->oldTtl === false) {
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
return;
}
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=' . $this->oldTtl);
});
function edgeGatewayCacheGatewayRow(
int $gatewayId,
int $departmentId,
string $status,
array $inventorySummary = ['total' => 0, 'online' => 0, 'offline' => 0],
array $bindingSummary = ['total' => 0, 'fallback_overrides' => 0],
array $fallbackSummary = ['cloud_only_relays' => 0, 'local_only_relays' => 0]
): array {
return [
'id' => $gatewayId,
'department_id' => $departmentId,
'label' => 'Gateway ' . $gatewayId,
'status' => $status,
'version_drift' => ['is_drifted' => false],
'channel_status' => ['broker' => ['connected' => $status === edge_gateway_manager::STATUS_ONLINE]],
'active_operation' => null,
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
'backlog_depth' => ['operations' => 0, 'commands' => 0],
'metadata' => [
'system_metrics' => [
'latency_ms' => 100,
'cpu_usage_pct' => 10,
'memory_usage_pct' => 20,
'disk_usage_pct' => 30,
],
],
'inventory_summary' => $inventorySummary,
'binding_summary' => $bindingSummary,
'fallback_summary' => $fallbackSummary,
'inventory' => [],
'bindings' => [],
'recent_commands' => [],
'audit_logs' => [],
'operations' => [],
];
}
it('uses sane ttl defaults and deterministic cache keys', function (): void {
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL');
expect(edge_gateway_view_cache::getTtl())->toBe(15);
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=25');
expect(edge_gateway_view_cache::getTtl())->toBe(25);
putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=-5');
expect(edge_gateway_view_cache::getTtl())->toBe(0);
expect(edge_gateway_view_cache::listKey(null, false))->toBe('edge_gateway:view:v1:list:department:all:detail:0');
expect(edge_gateway_view_cache::detailKey(701))->toBe('edge_gateway:view:v1:detail:701');
});
it('stores and retrieves cached list and detail payloads', function (): void {
$gateway = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE, ['total' => 2, 'online' => 1, 'offline' => 1]);
$payload = [
'gateways' => [$gateway],
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gateway]),
];
edge_gateway_view_cache::storeListPayload(null, false, $payload, 30);
edge_gateway_view_cache::storeDetailPayload(701, $gateway, 30);
expect(edge_gateway_view_cache::getListPayload(null, false))->toBe($payload);
expect(edge_gateway_view_cache::getDetailPayload(701))->toBe($gateway);
});
it('syncs gateway snapshots into cached list payloads and refreshes fleet usage', function (): void {
$staleGateway = edgeGatewayCacheGatewayRow(
701,
1,
edge_gateway_manager::STATUS_OFFLINE,
['total' => 1, 'online' => 0, 'offline' => 1],
['total' => 1, 'fallback_overrides' => 0],
['cloud_only_relays' => 0, 'local_only_relays' => 0]
);
$otherGateway = edgeGatewayCacheGatewayRow(
702,
2,
edge_gateway_manager::STATUS_ONLINE,
['total' => 1, 'online' => 1, 'offline' => 0],
['total' => 1, 'fallback_overrides' => 1],
['cloud_only_relays' => 1, 'local_only_relays' => 0]
);
edge_gateway_view_cache::storeListPayload(null, false, [
'gateways' => [$staleGateway, $otherGateway],
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway, $otherGateway]),
]);
edge_gateway_view_cache::storeListPayload(1, false, [
'gateways' => [$staleGateway],
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$staleGateway]),
]);
$freshGateway = [
'id' => 701,
'department_id' => 1,
'label' => 'Gateway 701',
'status' => edge_gateway_manager::STATUS_ONLINE,
'version_drift' => ['is_drifted' => false],
'channel_status' => ['broker' => ['connected' => true]],
'active_operation' => null,
'recent_operations_summary' => ['pending' => 0, 'in_progress' => 0],
'backlog_depth' => ['operations' => 0, 'commands' => 0],
'metadata' => [
'system_metrics' => [
'latency_ms' => 150,
'cpu_usage_pct' => 15,
'memory_usage_pct' => 25,
'disk_usage_pct' => 35,
],
],
'inventory' => [
['id' => 1, 'online' => true],
['id' => 2, 'online' => true],
],
'bindings' => [
['id' => 1, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL],
['id' => 2, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_CLOUD_ONLY],
],
'recent_commands' => [],
'audit_logs' => [],
'operations' => [],
'fallback_summary' => ['cloud_only_relays' => 1, 'local_only_relays' => 0],
];
edge_gateway_view_cache::syncGateway($freshGateway);
$allGatewaysPayload = edge_gateway_view_cache::getListPayload(null, false);
$departmentPayload = edge_gateway_view_cache::getListPayload(1, false);
$detailPayload = edge_gateway_view_cache::getDetailPayload(701);
expect($detailPayload)->not->toBeNull();
expect($detailPayload['inventory_summary'])->toBe(['total' => 2, 'online' => 2, 'offline' => 0]);
expect($detailPayload['binding_summary'])->toBe(['total' => 2, 'fallback_overrides' => 1]);
expect($allGatewaysPayload)->not->toBeNull();
expect($allGatewaysPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE);
expect($allGatewaysPayload['gateways'][0]['inventory'])->toBe([]);
expect($allGatewaysPayload['fleet_usage']['gateways']['online'])->toBe(2);
expect($allGatewaysPayload['fleet_usage']['inventory']['online'])->toBe(3);
expect($allGatewaysPayload['fleet_usage']['bindings']['cloud_only'])->toBe(2);
expect($departmentPayload)->not->toBeNull();
expect($departmentPayload['gateways'][0]['status'])->toBe(edge_gateway_manager::STATUS_ONLINE);
expect($departmentPayload['fleet_usage']['inventory']['online'])->toBe(2);
});
it('removes deleted gateways from cached list and detail payloads', function (): void {
$gatewayA = edgeGatewayCacheGatewayRow(701, 1, edge_gateway_manager::STATUS_ONLINE);
$gatewayB = edgeGatewayCacheGatewayRow(702, 1, edge_gateway_manager::STATUS_OFFLINE);
$payload = [
'gateways' => [$gatewayA, $gatewayB],
'fleet_usage' => edge_gateway_manager::summarizeFleetUsageFromGatewayRows([$gatewayA, $gatewayB]),
];
edge_gateway_view_cache::storeListPayload(null, false, $payload);
edge_gateway_view_cache::storeListPayload(1, false, $payload);
edge_gateway_view_cache::storeDetailPayload(701, $gatewayA);
edge_gateway_view_cache::removeGateway(701, 1);
expect(edge_gateway_view_cache::getDetailPayload(701))->toBeNull();
expect(edge_gateway_view_cache::getListPayload(null, false)['gateways'])->toHaveCount(1);
expect(edge_gateway_view_cache::getListPayload(1, false)['gateways'])->toHaveCount(1);
expect(edge_gateway_view_cache::getListPayload(null, false)['fleet_usage']['gateways']['total'])->toBe(1);
});
@@ -0,0 +1,23 @@
<?php
it('adds lane-aware scanner management and a dedicated rotate-key action', function (): void {
$route = file_get_contents(app_path('routes/plateScannersRoute.php'));
$scannerObject = file_get_contents(app_path('objects/plate_scanners_o.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/numberplatescanners/{id}/rotate-key'");
expect($route)->toContain("'lane_id'");
expect($scannerObject)->not->toBeFalse();
expect($scannerObject)->toContain('public object_property $lane_id;');
expect($scannerObject)->toContain('public function rotateApiKey(int $id): array');
expect($scannerObject)->toContain('ADD COLUMN `lane_id` INT NULL AFTER `department_id`');
});
it('uses the scanner default lane before requiring an explicit lane_id in machine button webhooks', function (): void {
$route = file_get_contents(app_path('routes/machineButtonPressRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('$plateScanner->lane_id->value()');
expect($route)->toContain('default lane configured for the plate scanner');
});