- Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes. - Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows. - Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management. - Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings.
785 lines
30 KiB
PHP
785 lines
30 KiB
PHP
<?php
|
|
|
|
namespace modules\selfserve\classes;
|
|
|
|
require_once WD . '/classes/db.php';
|
|
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
|
|
|
use classes\db;
|
|
use classes\selfserve_schema_bootstrap;
|
|
|
|
class selfserve_virtual_hardware
|
|
{
|
|
public const SCHEMA_VERSION = 1;
|
|
public const DEFAULT_GATEWAY_KEY = 'virtual-main';
|
|
|
|
public function __construct()
|
|
{
|
|
selfserve_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function getConfig(int $departmentId): array
|
|
{
|
|
$pdo = db::getPDO();
|
|
$statement = $pdo->prepare(
|
|
'SELECT config_json
|
|
FROM department_selfserve_studio_virtual_hardware
|
|
WHERE department_id = :department_id AND deleted_at IS NULL
|
|
LIMIT 1'
|
|
);
|
|
$statement->execute([':department_id' => $departmentId]);
|
|
$row = $statement->fetch(\PDO::FETCH_ASSOC);
|
|
if (!is_array($row)) {
|
|
return $this->emptyConfig();
|
|
}
|
|
|
|
$decoded = json_decode((string)($row['config_json'] ?? '{}'), true);
|
|
return $this->normalizeConfig(is_array($decoded) ? $decoded : []);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function saveConfig(int $departmentId, array $config, ?int $userId = null): array
|
|
{
|
|
$normalized = $this->normalizeConfig($config);
|
|
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) {
|
|
throw new \RuntimeException('Unable to encode virtual hardware config.');
|
|
}
|
|
|
|
$pdo = db::getPDO();
|
|
$statement = $pdo->prepare(
|
|
'INSERT INTO department_selfserve_studio_virtual_hardware
|
|
(department_id, config_json, created_by, updated_by, deleted_at)
|
|
VALUES
|
|
(:department_id, :config_json, :created_by, :updated_by, NULL)
|
|
ON DUPLICATE KEY UPDATE
|
|
config_json = VALUES(config_json),
|
|
updated_by = VALUES(updated_by),
|
|
deleted_at = NULL'
|
|
);
|
|
$statement->execute([
|
|
':department_id' => $departmentId,
|
|
':config_json' => $json,
|
|
':created_by' => $userId,
|
|
':updated_by' => $userId,
|
|
]);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $payload
|
|
* @param array<string,mixed> $realWorkspace
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array
|
|
{
|
|
$config = $this->getConfig($departmentId);
|
|
$operation = strtolower(trim($operation));
|
|
|
|
if ($operation === 'generate_from_lanes') {
|
|
$config = $this->generateFromLanes($realWorkspace, $config);
|
|
} elseif ($operation === 'upsert_gateway') {
|
|
$config = $this->upsertGateway($config, $payload);
|
|
} elseif ($operation === 'upsert_binding') {
|
|
$config = $this->upsertBinding($config, $payload, $realWorkspace);
|
|
} elseif ($operation === 'delete_binding') {
|
|
$config = $this->deleteBinding($config, $payload);
|
|
} elseif ($operation === 'reset') {
|
|
$config = $this->emptyConfig();
|
|
} else {
|
|
throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation);
|
|
}
|
|
|
|
return $this->saveConfig($departmentId, $config, $userId);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $workspace
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function mergeWorkspace(array $workspace, int $departmentId): array
|
|
{
|
|
return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId));
|
|
}
|
|
|
|
/**
|
|
* Pure merge helper used by graph serialization and tests.
|
|
*
|
|
* @param array<string,mixed> $workspace
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function mergeWorkspaceWithConfig(array $workspace, array $config): array
|
|
{
|
|
$config = $this->normalizeConfig($config);
|
|
$workspace += [
|
|
'gateways' => [],
|
|
'relays' => [],
|
|
'lanes' => [],
|
|
'issues' => [],
|
|
'actions' => [],
|
|
];
|
|
|
|
$virtualBindings = (array)($config['bindings'] ?? []);
|
|
$enabled = (bool)($config['enabled'] ?? true);
|
|
if (!$enabled || $virtualBindings === []) {
|
|
$workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0);
|
|
return $workspace;
|
|
}
|
|
|
|
$realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? []));
|
|
$virtualGateways = $this->virtualGatewaysForWorkspace($config);
|
|
$virtualRelays = $this->virtualRelaysForWorkspace($config);
|
|
$bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways);
|
|
|
|
$workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways));
|
|
$workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays);
|
|
$workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId);
|
|
|
|
$coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true);
|
|
$workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways);
|
|
$workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds);
|
|
$workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace);
|
|
|
|
return $workspace;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $workspace
|
|
* @return array<int,string>
|
|
*/
|
|
public function validationWarnings(array $workspace): array
|
|
{
|
|
$virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : [];
|
|
if (($virtual['has_virtual_hardware'] ?? false) !== true) {
|
|
return [];
|
|
}
|
|
|
|
$warnings = [
|
|
'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.',
|
|
];
|
|
$virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []);
|
|
if ($virtualOnlyRelays !== []) {
|
|
$warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.';
|
|
}
|
|
|
|
return $warnings;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $workspace
|
|
* @param array<string,mixed>|null $baseConfig
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function generateFromLanes(array $workspace, ?array $baseConfig = null): array
|
|
{
|
|
$config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig());
|
|
$gatewayKey = self::DEFAULT_GATEWAY_KEY;
|
|
$config = $this->upsertGateway($config, [
|
|
'key' => $gatewayKey,
|
|
'label' => 'Virtual Studio Gateway',
|
|
'status' => 'VIRTUAL',
|
|
]);
|
|
|
|
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
|
|
if (!is_array($lane)) {
|
|
continue;
|
|
}
|
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
|
if (!is_array($slot)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($slot['relay_id'] ?? ''));
|
|
$role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? '');
|
|
if ($relayId === '' || $role === '') {
|
|
continue;
|
|
}
|
|
$config = $this->upsertBinding($config, [
|
|
'gateway_key' => $gatewayKey,
|
|
'relay_id' => $relayId,
|
|
'role' => $role,
|
|
'services' => [$role],
|
|
'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role,
|
|
'lane_id' => (int)($lane['id'] ?? 0),
|
|
'slot' => $role,
|
|
'generated' => true,
|
|
], $workspace);
|
|
}
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function emptyConfig(): array
|
|
{
|
|
return [
|
|
'schema_version' => self::SCHEMA_VERSION,
|
|
'enabled' => true,
|
|
'gateways' => [],
|
|
'relays' => [],
|
|
'bindings' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function normalizeConfig(array $config): array
|
|
{
|
|
$normalized = $this->emptyConfig();
|
|
$normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION);
|
|
$normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
|
|
|
|
foreach ((array)($config['gateways'] ?? []) as $gateway) {
|
|
if (!is_array($gateway)) {
|
|
continue;
|
|
}
|
|
$key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '');
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
$normalized['gateways'][$key] = [
|
|
'key' => $key,
|
|
'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))),
|
|
'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
|
'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [],
|
|
];
|
|
}
|
|
|
|
foreach ((array)($config['relays'] ?? []) as $relay) {
|
|
if (!is_array($relay)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
|
|
if ($relayId === '') {
|
|
continue;
|
|
}
|
|
$normalized['relays'][$relayId] = [
|
|
'relay_id' => $relayId,
|
|
'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))),
|
|
'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
|
'virtual' => true,
|
|
];
|
|
}
|
|
|
|
$bindings = [];
|
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
|
if (!is_array($binding)) {
|
|
continue;
|
|
}
|
|
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
|
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
|
$role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? '');
|
|
if ($gatewayKey === '' || $relayId === '') {
|
|
continue;
|
|
}
|
|
if (!isset($normalized['gateways'][$gatewayKey])) {
|
|
$normalized['gateways'][$gatewayKey] = [
|
|
'key' => $gatewayKey,
|
|
'label' => 'Virtual Gateway ' . $gatewayKey,
|
|
'status' => 'VIRTUAL',
|
|
'metadata' => [],
|
|
];
|
|
}
|
|
if (!isset($normalized['relays'][$relayId])) {
|
|
$normalized['relays'][$relayId] = [
|
|
'relay_id' => $relayId,
|
|
'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))),
|
|
'status' => 'VIRTUAL',
|
|
'virtual' => true,
|
|
];
|
|
}
|
|
$services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : []));
|
|
if ($services === [] && $role !== '') {
|
|
$services = [$role];
|
|
}
|
|
$id = $this->bindingId($gatewayKey, $relayId, $role);
|
|
$bindings[$id] = [
|
|
'id' => $id,
|
|
'gateway_key' => $gatewayKey,
|
|
'relay_id' => $relayId,
|
|
'role' => $role,
|
|
'slot' => $role,
|
|
'services' => $services,
|
|
'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
|
|
'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0,
|
|
'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null,
|
|
'generated' => (bool)($binding['generated'] ?? false),
|
|
'virtual' => true,
|
|
];
|
|
}
|
|
|
|
$normalized['gateways'] = array_values($normalized['gateways']);
|
|
$normalized['relays'] = array_values($normalized['relays']);
|
|
$normalized['bindings'] = array_values($bindings);
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @param array<string,mixed> $payload
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function upsertGateway(array $config, array $payload): array
|
|
{
|
|
$config = $this->normalizeConfig($config);
|
|
$key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY);
|
|
if ($key === '') {
|
|
throw new \RuntimeException('Virtual gateway key is required.');
|
|
}
|
|
|
|
$gateways = [];
|
|
foreach ((array)$config['gateways'] as $gateway) {
|
|
$gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway;
|
|
}
|
|
$gateways[$key] = [
|
|
'key' => $key,
|
|
'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))),
|
|
'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
|
|
'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []),
|
|
];
|
|
$config['gateways'] = array_values($gateways);
|
|
|
|
return $this->normalizeConfig($config);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @param array<string,mixed> $payload
|
|
* @param array<string,mixed> $workspace
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function upsertBinding(array $config, array $payload, array $workspace): array
|
|
{
|
|
$config = $this->normalizeConfig($config);
|
|
$gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
|
|
$relayId = trim((string)($payload['relay_id'] ?? ''));
|
|
$role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? '');
|
|
if ($gatewayKey === '' || $relayId === '') {
|
|
throw new \RuntimeException('Virtual gateway key and relay id are required.');
|
|
}
|
|
|
|
$config = $this->upsertGateway($config, ['key' => $gatewayKey]);
|
|
$services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : []));
|
|
if ($services === [] && $role !== '') {
|
|
$services = [$role];
|
|
}
|
|
$binding = [
|
|
'id' => $this->bindingId($gatewayKey, $relayId, $role),
|
|
'gateway_key' => $gatewayKey,
|
|
'relay_id' => $relayId,
|
|
'role' => $role,
|
|
'slot' => $role,
|
|
'services' => $services,
|
|
'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
|
|
'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0,
|
|
'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId),
|
|
'generated' => (bool)($payload['generated'] ?? false),
|
|
'virtual' => true,
|
|
];
|
|
|
|
$bindings = [];
|
|
foreach ((array)$config['bindings'] as $existing) {
|
|
if (!is_array($existing)) {
|
|
continue;
|
|
}
|
|
$bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing;
|
|
}
|
|
$bindings[$binding['id']] = $binding;
|
|
$config['bindings'] = array_values($bindings);
|
|
$config['relays'][] = [
|
|
'relay_id' => $relayId,
|
|
'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))),
|
|
'status' => 'VIRTUAL',
|
|
'virtual' => true,
|
|
];
|
|
|
|
return $this->normalizeConfig($config);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @param array<string,mixed> $payload
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function deleteBinding(array $config, array $payload): array
|
|
{
|
|
$config = $this->normalizeConfig($config);
|
|
$id = trim((string)($payload['id'] ?? ''));
|
|
if ($id === '') {
|
|
$id = $this->bindingId(
|
|
$this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY),
|
|
trim((string)($payload['relay_id'] ?? '')),
|
|
$this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '')
|
|
);
|
|
}
|
|
|
|
$config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool {
|
|
return (string)($binding['id'] ?? '') !== $id;
|
|
}));
|
|
|
|
return $this->normalizeConfig($config);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function virtualGatewaysForWorkspace(array $config): array
|
|
{
|
|
$bindingsByGateway = [];
|
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
|
if (!is_array($binding)) {
|
|
continue;
|
|
}
|
|
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY);
|
|
$bindingsByGateway[$gatewayKey][] = [
|
|
...$binding,
|
|
'gateway_id' => $gatewayKey,
|
|
'gateway_key' => $gatewayKey,
|
|
'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []),
|
|
'virtual' => true,
|
|
];
|
|
}
|
|
|
|
$gateways = [];
|
|
foreach ((array)($config['gateways'] ?? []) as $gateway) {
|
|
if (!is_array($gateway)) {
|
|
continue;
|
|
}
|
|
$key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY);
|
|
$bindings = array_values($bindingsByGateway[$key] ?? []);
|
|
if ($bindings === []) {
|
|
continue;
|
|
}
|
|
$gateways[] = [
|
|
'id' => $key,
|
|
'key' => $key,
|
|
'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)),
|
|
'status' => (string)($gateway['status'] ?? 'VIRTUAL'),
|
|
'virtual' => true,
|
|
'studio_only' => true,
|
|
'bindings' => $bindings,
|
|
'metadata' => (array)($gateway['metadata'] ?? []),
|
|
];
|
|
}
|
|
|
|
return $gateways;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function virtualRelaysForWorkspace(array $config): array
|
|
{
|
|
return array_values(array_map(static function (array $relay): array {
|
|
return [
|
|
...$relay,
|
|
'virtual' => true,
|
|
'studio_only' => true,
|
|
];
|
|
}, (array)($config['relays'] ?? [])));
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $gateways
|
|
* @return array<string,array<int,array<string,mixed>>>
|
|
*/
|
|
private function indexVirtualBindingsByRelayId(array $gateways): array
|
|
{
|
|
$bindings = [];
|
|
foreach ($gateways as $gateway) {
|
|
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
|
|
if (!is_array($binding)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
|
if ($relayId === '') {
|
|
continue;
|
|
}
|
|
$bindings[$relayId][] = [
|
|
...$binding,
|
|
'gateway_id' => (string)($gateway['id'] ?? ''),
|
|
'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'),
|
|
'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'),
|
|
'virtual' => true,
|
|
'studio_only' => true,
|
|
];
|
|
}
|
|
}
|
|
return $bindings;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $realRelays
|
|
* @param array<int,array<string,mixed>> $virtualRelays
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function mergeRelays(array $realRelays, array $virtualRelays): array
|
|
{
|
|
$rows = [];
|
|
foreach (array_merge($realRelays, $virtualRelays) as $relay) {
|
|
if (!is_array($relay)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
|
|
if ($relayId === '') {
|
|
continue;
|
|
}
|
|
$rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay;
|
|
}
|
|
return array_values($rows);
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $lanes
|
|
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array
|
|
{
|
|
foreach ($lanes as $laneIndex => $lane) {
|
|
if (!is_array($lane)) {
|
|
continue;
|
|
}
|
|
$required = 0;
|
|
$bound = 0;
|
|
foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) {
|
|
if (!is_array($slot)) {
|
|
continue;
|
|
}
|
|
$required += 1;
|
|
$relayId = trim((string)($slot['relay_id'] ?? ''));
|
|
$covered = (bool)($slot['coverage']['covered'] ?? false);
|
|
if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) {
|
|
$slot['coverage'] = [
|
|
'relay_id' => $relayId,
|
|
'covered' => true,
|
|
'status' => 'VIRTUAL',
|
|
'binding_count' => count($bindingsByRelayId[$relayId]),
|
|
'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null,
|
|
'bindings' => $bindingsByRelayId[$relayId],
|
|
'virtual' => true,
|
|
'studio_only' => true,
|
|
];
|
|
$slot['virtual'] = true;
|
|
$covered = true;
|
|
}
|
|
if ($covered) {
|
|
$bound += 1;
|
|
}
|
|
$lane['relay_slots'][$slotIndex] = $slot;
|
|
}
|
|
$lane['binding_coverage'] = [
|
|
'required' => $required,
|
|
'bound' => $bound,
|
|
'missing' => max(0, $required - $bound),
|
|
'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'),
|
|
'virtual' => $this->laneHasVirtualCoverage($lane),
|
|
];
|
|
$lanes[$laneIndex] = $lane;
|
|
}
|
|
|
|
return $lanes;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $lane
|
|
*/
|
|
private function laneHasVirtualCoverage(array $lane): bool
|
|
{
|
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
|
if (is_array($slot) && !empty($slot['coverage']['virtual'])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $issues
|
|
* @param array<string,bool> $coveredRelayIds
|
|
* @param array<int,array<string,mixed>> $virtualGateways
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array
|
|
{
|
|
$filtered = [];
|
|
foreach ($issues as $issue) {
|
|
if (!is_array($issue)) {
|
|
continue;
|
|
}
|
|
$code = strtoupper((string)($issue['code'] ?? ''));
|
|
if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) {
|
|
$filtered[] = [
|
|
...$issue,
|
|
'severity' => 'info',
|
|
'virtual' => true,
|
|
'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.',
|
|
];
|
|
continue;
|
|
}
|
|
if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) {
|
|
$filtered[] = [
|
|
...$issue,
|
|
'severity' => 'warning',
|
|
'virtual' => true,
|
|
'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.',
|
|
];
|
|
continue;
|
|
}
|
|
$filtered[] = $issue;
|
|
}
|
|
|
|
$filtered[] = [
|
|
'severity' => 'warning',
|
|
'code' => 'VIRTUAL_HARDWARE_ACTIVE',
|
|
'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.',
|
|
'virtual' => true,
|
|
'relay_ids' => array_keys($coveredRelayIds),
|
|
];
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $summary
|
|
* @param array<string,mixed> $workspace
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function mergeSummary(array $summary, array $workspace): array
|
|
{
|
|
$virtual = (array)($workspace['virtual'] ?? []);
|
|
$summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0);
|
|
$summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0);
|
|
$summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false);
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $config
|
|
* @return array<string,mixed>
|
|
*/
|
|
/**
|
|
* @param array<string,bool> $realBindingRelayIds
|
|
*/
|
|
private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array
|
|
{
|
|
$relayIds = [];
|
|
foreach ((array)($config['bindings'] ?? []) as $binding) {
|
|
if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') {
|
|
$relayId = trim((string)$binding['relay_id']);
|
|
if (!isset($realBindingRelayIds[$relayId])) {
|
|
$relayIds[] = $relayId;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION),
|
|
'enabled' => (bool)($config['enabled'] ?? true),
|
|
'has_virtual_hardware' => $bindingCount > 0,
|
|
'gateway_count' => $gatewayCount,
|
|
'binding_count' => $bindingCount,
|
|
'virtual_only_relay_ids' => array_values(array_unique($relayIds)),
|
|
'config' => $config,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $gateways
|
|
* @return array<string,bool>
|
|
*/
|
|
private function realBindingRelayIds(array $gateways): array
|
|
{
|
|
$relayIds = [];
|
|
foreach ($gateways as $gateway) {
|
|
if (!is_array($gateway) || !empty($gateway['virtual'])) {
|
|
continue;
|
|
}
|
|
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
|
|
if (!is_array($binding)) {
|
|
continue;
|
|
}
|
|
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
|
if ($relayId !== '') {
|
|
$relayIds[$relayId] = true;
|
|
}
|
|
}
|
|
}
|
|
return $relayIds;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $workspace
|
|
*/
|
|
private function laneIdForRelay(array $workspace, string $relayId): ?int
|
|
{
|
|
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
|
|
if (!is_array($lane)) {
|
|
continue;
|
|
}
|
|
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
|
|
if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) {
|
|
return (int)($lane['id'] ?? 0) ?: null;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function normalizeGatewayKey(mixed $value): string
|
|
{
|
|
return trim((string)$value);
|
|
}
|
|
|
|
private function normalizeRole(mixed $value): string
|
|
{
|
|
return strtoupper(trim((string)$value));
|
|
}
|
|
|
|
/**
|
|
* @return array<int,string>
|
|
*/
|
|
private function normalizeServiceList(mixed $value): array
|
|
{
|
|
if (is_string($value)) {
|
|
$decoded = json_decode($value, true);
|
|
$value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value);
|
|
}
|
|
if (!is_array($value)) {
|
|
$value = [$value];
|
|
}
|
|
|
|
$services = [];
|
|
foreach ($value as $entry) {
|
|
if (is_array($entry)) {
|
|
continue;
|
|
}
|
|
$service = $this->normalizeRole($entry);
|
|
if ($service !== '') {
|
|
$services[$service] = true;
|
|
}
|
|
}
|
|
return array_keys($services);
|
|
}
|
|
|
|
private function bindingId(string $gatewayKey, string $relayId, string $role): string
|
|
{
|
|
return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay');
|
|
}
|
|
}
|