diff --git a/application/RaspberryDisplay/RaspberryDisplayController.php b/application/RaspberryDisplay/RaspberryDisplayController.php index 3b51fe184..15d0839ff 100644 --- a/application/RaspberryDisplay/RaspberryDisplayController.php +++ b/application/RaspberryDisplay/RaspberryDisplayController.php @@ -1,11 +1,6 @@ request->do; - - if ($do !== "getConfig" && !$this->me->is("employee")) { - $this->redirect("dashboard"); + if (!$this->me->is("employee")) { + $this->returnJson(["status" => "error", "message" => "Unauthorized"]); + return; } - switch ($do) { - case "getDisplays": - $return = $this->getDisplaysApi(); - break; - case "change": - $return = $this->change(); - break; - case "reboot": - $return = $this->restartRaspberryPi($this->request->displayID); - break; - case "rebootAll": - $return = $this->restartAllRaspberryPis(); - break; - case "getConfig": - $return = $this->getConfig(); - break; - case "displayPower": - $return = $this->displayPower(); - break; - default: - $return = false; - break; + $return = match($this->request->do) { + "getDisplays" => $this->getDisplaysApi(), + "getGroups" => RaspberryDisplayModel::getGroups(), + "createDisplay" => $this->createDisplayApi(), + "updateDisplay" => $this->updateDisplayApi(), + "deleteDisplay" => RaspberryDisplayModel::delete((int)$this->request->id), + "updateOrder" => $this->updateOrderApi(), + "discoverPi" => $this->discoverPiApi(), + "getStatus" => $this->getStatusApi(), + "getBatchStatus" => $this->getBatchStatusApi(), + "setUrl" => $this->setUrlApi(), + "refreshDisplay" => $this->refreshDisplayApi(), + "cecPower" => $this->cecPowerApi(), + "rebootPi" => $this->rebootPiApi(), + "refreshAll" => $this->refreshAllApi(), + "powerAll" => $this->powerAllApi(), + "rebootAll" => $this->rebootAllApi(), + default => null + }; + + if ($return === null) { + $this->returnJson(["status" => "error", "message" => "Unknown action"]); + } elseif ($return === true) { + $this->returnJson(["status" => "success"]); + } elseif ($return === false) { + $this->returnJson(["status" => "error", "message" => "Operation failed"]); + } else { + $this->returnJson(["status" => "OK", "result" => $return]); } - - - $data = []; - - if ($return === true) { - $data = ["status" => "success"]; - $this->returnJson($data); - } - - if (!is_array($return) || !count($return)) { - $data = ["status" => "error"]; - $this->returnJson($data); - } - $data['status'] = "OK"; - $data['result'] = $return; - $this->returnJson($data); } protected function getDisplaysApi(): array { - $displays = RaspberryDisplayModel::getAll(); - $result = []; - foreach ($displays as $display) { - $result[] = ["display_label" => $display->display_label, + $grouped = []; + foreach (RaspberryDisplayModel::getAll() as $display) { + $grouped[$display->group_name][] = [ + "id" => (int)$display->id, + "display_label" => $display->display_label, "hostname" => $display->hostname, - "ip" => $display->ip_address, + "ip_address" => $display->ip_address, "display_url" => $display->display_url, - "auto_refresh_enabled" => $display->auto_refresh_enabled === "1", - "margin_hot_fix_enabled" => $display->margin_hot_fix_enabled === "1", + "group_name" => $display->group_name, + "group_order" => (int)$display->group_order, + "monitor_size" => $display->monitor_size, + "hdmi_port" => (int)$display->hdmi_port, + "agent_port" => (int)$display->agent_port, "custom_style" => $display->custom_style, - "id" => $display->id,]; + ]; + } + foreach ($grouped as &$group) { + usort($group, fn($a, $b) => $a['group_order'] <=> $b['group_order']); + } + return $grouped; + } + + protected function createDisplayApi(): array|bool { + $data = [ + 'display_label' => $this->request->display_label ?? '', + 'hostname' => $this->request->hostname ?? '', + 'ip_address' => $this->request->ip_address ?? '', + 'display_url' => $this->request->display_url ?? '', + 'group_name' => $this->request->group_name ?? '', + 'group_order' => (int)($this->request->group_order ?? 0), + 'monitor_size' => $this->request->monitor_size ?? '27', + 'hdmi_port' => (int)($this->request->hdmi_port ?? 0), + 'agent_port' => (int)($this->request->agent_port ?? 5000), + 'custom_style' => $this->request->custom_style ?? null, + ]; + + if (empty($data['display_label']) || empty($data['ip_address']) || empty($data['group_name'])) { + return false; + } + + $display = RaspberryDisplayModel::create($data); + $display->create_by = $this->me->id; + $display->create = time(); + RaspberryDisplayModel::save($display); + return ['id' => $display->id]; + } + + protected function updateDisplayApi(): bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + + foreach (['display_label', 'hostname', 'ip_address', 'display_url', 'group_name', 'group_order', 'monitor_size', 'hdmi_port', 'agent_port', 'custom_style'] as $field) { + if (isset($this->request->$field)) $display->$field = $this->request->$field; + } + $display->edit_by = $this->me->id; + $display->edit = time(); + RaspberryDisplayModel::save($display); + return true; + } + + protected function updateOrderApi(): bool { + if (!is_array($this->request->orders)) return false; + foreach ($this->request->orders as $order) { + $display = RaspberryDisplayModel::get((int)$order['id']); + if ($display) { + $display->group_name = $order['group_name'] ?? $display->group_name; + $display->group_order = (int)($order['group_order'] ?? 0); + $display->edit_by = $this->me->id; + $display->edit = time(); + RaspberryDisplayModel::save($display); + } + } + return true; + } + + protected function discoverPiApi(): array|bool { + $ip = $this->request->ip_address ?? ''; + if (empty($ip)) return false; + + $status = (new NocDisplayAgent($ip, (int)($this->request->agent_port ?? 5000)))->status(); + if (!$status['success']) { + return ['online' => false, 'error' => $status['error'] ?? 'Failed to connect']; + } + return [ + 'online' => true, + 'hostname' => $status['hostname'] ?? '', + 'displays' => $status['displays'] ?? [], + 'temperature_c' => $status['temperature_c'] ?? null, + 'cpu_percent' => $status['cpu_percent'] ?? null, + 'memory' => $status['memory'] ?? null, + ]; + } + + protected function getStatusApi(): array|bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + return NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->status(); + } + + protected function getBatchStatusApi(): array { + $seen = []; + $results = []; + foreach (RaspberryDisplayModel::getAll() as $display) { + $key = "{$display->ip_address}:{$display->agent_port}"; + if (isset($seen[$key])) continue; + $seen[$key] = true; + $results[$display->ip_address] = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->status(); + } + return $results; + } + + protected function setUrlApi(): array|bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + + $result = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port]) + ->setUrl((int)$display->hdmi_port, $this->request->url ?? ''); + + if ($result['success'] ?? false) { + $display->display_url = $this->request->url ?? ''; + $display->edit_by = $this->me->id; + $display->edit = time(); + RaspberryDisplayModel::save($display); } return $result; } - protected function change() { - $displayID = $this->request->displayID; - $field = $this->request->field; - $value = $this->request->value; - $value = $value === "true" ? 1 : ($value === "false" ? 0 : $value); - $display = RaspberryDisplayModel::get($displayID); - if ($display === null) { - return false; - } - $display->$field = $value; - $display->save(); - return true; + protected function refreshDisplayApi(): array|bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + return NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->refresh((int)$display->hdmi_port); } - protected function restartRaspberryPi($id) { - $display = RaspberryDisplayModel::get($id); - - $ssh = new SSH2($display->ip_address, $this->port); - $ssh->login($this->username, $this->password); - $ssh->exec('sudo reboot now'); - return true; + protected function cecPowerApi(): array|bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + $agent = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port]); + return ($this->request->state ?? 'on') === 'on' ? $agent->cecOn((int)$display->hdmi_port) : $agent->cecOff((int)$display->hdmi_port); } - protected function restartAllRaspberryPis() { - $displays = RaspberryDisplayModel::getAll(); - $ipAddresses = []; - foreach ($displays as $display) { - if (in_array($display->ip_address, $ipAddresses)) { - continue; - } - $ipAddresses[] = $display->ip_address; - $ssh = new SSH2($display->ip_address, $this->port); - $ssh->login($this->username, $this->password); - $ssh->exec('sudo reboot now'); - } - return true; + protected function rebootPiApi(): array|bool { + $display = RaspberryDisplayModel::get((int)$this->request->id); + if (!$display) return false; + return NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->reboot(); } - protected function getConfig() { - $hostname = $this->request->hostname; - - $displays = RaspberryDisplayModel::getByHostname($hostname); - - if ($displays === null) { - die("No display found for this hostname and ip:" . $hostname . " X "); + protected function refreshAllApi(): array { + $results = []; + foreach (RaspberryDisplayModel::getAll() as $display) { + $results[$display->id] = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->refresh((int)$display->hdmi_port); } + return $results; + } - return array_map(function ($display) { - return ["display_url" => $display->data->display_url, - "auto_refresh_enabled" => $display->data->auto_refresh_enabled === "1", - "margin_hot_fix_enabled" => $display->data->margin_hot_fix_enabled === "1", - "id" => $display->id,]; - }, $displays); + protected function powerAllApi(): array { + $state = $this->request->state ?? 'on'; + $results = []; + foreach (RaspberryDisplayModel::getAll() as $display) { + $agent = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port]); + $results[$display->id] = $state === 'on' ? $agent->cecOn((int)$display->hdmi_port) : $agent->cecOff((int)$display->hdmi_port); + } + return $results; + } + + protected function rebootAllApi(): array { + $seen = []; + $results = []; + foreach (RaspberryDisplayModel::getAll() as $display) { + $key = "{$display->ip_address}:{$display->agent_port}"; + if (isset($seen[$key])) continue; + $seen[$key] = true; + $results[$display->ip_address] = NocDisplayAgent::fromDisplay(['ip_address' => $display->ip_address, 'agent_port' => $display->agent_port])->reboot(); + } + return $results; } protected function indexAction(): void { - $JSGlobals = ["BASE_URL" => self::getUrl("RaspberryDisplay"), - "DASHBOARD_URL" => self::getUrl("Dashboard"), - "MFAPPNAME" => MFAPPNAME_SLUG, - "PAGE_TITLE" => "Raspberry Displays", - "PATH" => [ - ["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")], - ["text" => "Raspberry Displays", "href" => self::getUrl("RaspberryDisplay")] - ] - ]; + if (!$this->me->is("employee")) { + $this->redirect("dashboard"); + return; + } $this->layout()->set("vueViewName", "RaspberryDisplay"); - $this->layout()->set("JSGlobals", $JSGlobals); + $this->layout()->set("JSGlobals", [ + "BASE_URL" => self::getUrl("RaspberryDisplay"), + "DASHBOARD_URL" => self::getUrl("Dashboard"), + "MFAPPNAME" => MFAPPNAME_SLUG, + "PAGE_TITLE" => "NOC Display Manager", + "PATH" => [ + ["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")], + ["text" => "NOC Display Manager", "href" => self::getUrl("RaspberryDisplay")] + ] + ]); $this->layout()->set("additionalCSS", ["css/views/RaspberryDisplay.css"]); - $this->layout()->setTemplate("VueViews/Vue"); + $this->layout()->setTemplate("VueViews/Vue3"); } - - private function displayPower(): bool - { - $state = $this->request->state; - $displays = RaspberryDisplayModel::getAll(); - $ipAddresses = []; - foreach ($displays as $display) { - if (in_array($display->ip_address, $ipAddresses)) { - continue; - } - $ipAddresses[] = $display->ip_address; - $ssh = new SSH2($display->ip_address, $this->port); - $ssh->login($this->username, $this->password); - if ($state === "on") { - $ssh->exec('sudo bash /root/on.sh'); - } else { - $ssh->exec('sudo bash /root/off.sh'); - } - } - return true; - } - - -} \ No newline at end of file +} diff --git a/application/RaspberryDisplay/RaspberryDisplayModel.php b/application/RaspberryDisplay/RaspberryDisplayModel.php index d96b40655..72ca9b3ec 100644 --- a/application/RaspberryDisplay/RaspberryDisplayModel.php +++ b/application/RaspberryDisplay/RaspberryDisplayModel.php @@ -6,8 +6,12 @@ class RaspberryDisplayModel public $hostname; public $ip_address; public $display_url; - public $auto_refresh_enabled; - public $margin_hot_fix_enabled; + public $group_name; + public $group_order; + public $monitor_size; + public $hdmi_port; + public $agent_port; + public $custom_style; public function __construct($data = []) { @@ -22,7 +26,7 @@ class RaspberryDisplayModel { $db = FronkDB::singleton(); - $res = $db->select("RaspberryDisplay", "*", "id = $id"); + $res = $db->select("RaspberryDisplay", "*", "id = " . (int)$id); if ($db->num_rows($res)) { return new RaspberryDisplay($db->fetch_object($res)); } @@ -33,28 +37,97 @@ class RaspberryDisplayModel { $db = FronkDB::singleton(); - $res = $db->select("RaspberryDisplay", "*", "hostname = '$hostname'"); - //fetch 2 rows + $res = $db->select("RaspberryDisplay", "*", "hostname = '" . $db->real_escape_string($hostname) . "'"); + $items = []; if ($db->num_rows($res)) { while ($data = $db->fetch_object($res)) { $items[] = new RaspberryDisplay($data); } - return $items; } + return $items; + } + public static function getByIpAddress($ipAddress) + { + $db = FronkDB::singleton(); + + $res = $db->select("RaspberryDisplay", "*", "ip_address = '" . $db->real_escape_string($ipAddress) . "'"); + $items = []; + + if ($db->num_rows($res)) { + while ($data = $db->fetch_object($res)) { + $items[] = new RaspberryDisplay($data); + } + } + return $items; + } + + public static function getByGroup($groupName) + { + $db = FronkDB::singleton(); + + $res = $db->select( + "RaspberryDisplay", + "*", + "group_name = '" . $db->real_escape_string($groupName) . "'", + "group_order ASC" + ); + $items = []; + + if ($db->num_rows($res)) { + while ($data = $db->fetch_object($res)) { + $items[] = new RaspberryDisplay($data); + } + } + return $items; + } + + public static function getGroups() + { + $db = FronkDB::singleton(); + + $res = $db->query("SELECT DISTINCT group_name FROM RaspberryDisplay ORDER BY group_name ASC"); + $groups = []; + + if ($db->num_rows($res)) { + while ($data = $db->fetch_object($res)) { + $groups[] = $data->group_name; + } + } + return $groups; } public static function create(array $data) { $model = new RaspberryDisplay(); - foreach ($data as $field => $value) { - if (property_exists(get_called_class(), $field)) { - $model->$field = $value; + $fields = [ + 'display_label', 'hostname', 'ip_address', 'display_url', + 'group_name', 'group_order', 'monitor_size', 'hdmi_port', + 'agent_port', 'custom_style' + ]; + + foreach ($fields as $field) { + if (isset($data[$field])) { + $model->$field = $data[$field]; } } + // Set defaults + if ($model->group_order === null) { + $model->group_order = 0; + } + if ($model->monitor_size === null) { + $model->monitor_size = '27'; + } + if ($model->hdmi_port === null) { + $model->hdmi_port = 0; + } + if ($model->agent_port === null) { + $model->agent_port = 5000; + } + $me = new User(); $me->loadMe(); @@ -74,14 +147,19 @@ class RaspberryDisplayModel $db = FronkDB::singleton(); - $res = $db->select("RaspberryDisplay", "id, display_label, hostname, ip_address,custom_style, display_url, auto_refresh_enabled, margin_hot_fix_enabled"); + $res = $db->select( + "RaspberryDisplay", + "id, display_label, hostname, ip_address, display_url, group_name, group_order, monitor_size, hdmi_port, agent_port, custom_style", + "", + "group_name ASC, group_order ASC" + ); + if ($db->num_rows($res)) { while ($data = $db->fetch_object($res)) { $items[] = new RaspberryDisplay($data); } } return $items; - } public static function save(RaspberryDisplay $model) @@ -91,13 +169,57 @@ class RaspberryDisplayModel $data = $model->data; if ($model->id) { - $db->update("RaspberryDisplay", $data, "id=" . $model->id); + $data['edit'] = time(); + $db->update("RaspberryDisplay", $data, "id=" . (int)$model->id); } else { - $model->create = date("U"); - $model->edit = date("U"); + $data['create'] = time(); + $data['edit'] = time(); $model->id = $db->insert("RaspberryDisplay", $data); } return $model; } -} \ No newline at end of file + + public static function delete($id) + { + $db = FronkDB::singleton(); + $db->delete("RaspberryDisplay", "id = " . (int)$id); + return true; + } + + public static function updateOrder(array $orders) + { + $db = FronkDB::singleton(); + + foreach ($orders as $order) { + $id = (int)$order['id']; + $groupName = $db->real_escape_string($order['group_name']); + $groupOrder = (int)$order['group_order']; + + $db->update("RaspberryDisplay", [ + 'group_name' => $groupName, + 'group_order' => $groupOrder, + 'edit' => time() + ], "id = $id"); + } + + return true; + } + + public static function getMaxOrderInGroup($groupName) + { + $db = FronkDB::singleton(); + + $res = $db->query( + "SELECT MAX(group_order) as max_order FROM RaspberryDisplay WHERE group_name = '" . + $db->real_escape_string($groupName) . "'" + ); + + if ($db->num_rows($res)) { + $data = $db->fetch_object($res); + return (int)($data->max_order ?? 0); + } + + return 0; + } +} diff --git a/db/migrations/20260203100000_revamp_raspberry_display_table.php b/db/migrations/20260203100000_revamp_raspberry_display_table.php new file mode 100644 index 000000000..662bee705 --- /dev/null +++ b/db/migrations/20260203100000_revamp_raspberry_display_table.php @@ -0,0 +1,62 @@ +getEnvironment() == "thetool") { + $this->table('RaspberryDisplay')->drop()->save(); + + $this->table('RaspberryDisplay') + ->addColumn('display_label', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('hostname', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('ip_address', 'string', ['limit' => 45, 'null' => false]) + ->addColumn('display_url', 'text', ['null' => true]) + ->addColumn('group_name', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('group_order', 'integer', ['default' => 0]) + ->addColumn('monitor_size', 'enum', ['values' => ['27', '42', '55', '65'], 'default' => '27']) + ->addColumn('hdmi_port', 'integer', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY, 'default' => 0]) + ->addColumn('agent_port', 'integer', ['default' => 5000]) + ->addColumn('custom_style', 'text', ['null' => true]) + ->addColumn('create', 'integer', ['null' => true]) + ->addColumn('edit', 'integer', ['null' => true]) + ->addColumn('create_by', 'integer', ['null' => true]) + ->addColumn('edit_by', 'integer', ['null' => true]) + ->addIndex(['group_name']) + ->addIndex(['ip_address']) + ->create(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table('RaspberryDisplay')->drop()->save(); + + $this->table('RaspberryDisplay') + ->addColumn('display_label', 'string', ['limit' => 255]) + ->addColumn('hostname', 'string', ['limit' => 255]) + ->addColumn('ip_address', 'string', ['limit' => 15]) + ->addColumn('display_url', 'string', ['limit' => 255]) + ->addColumn('auto_refresh_enabled', 'boolean', ['default' => false]) + ->addColumn('margin_hot_fix_enabled', 'boolean', ['default' => false]) + ->addColumn('custom_style', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('create', 'integer', ['null' => true]) + ->addColumn('edit', 'integer', ['null' => true]) + ->addColumn('create_by', 'integer', ['null' => true]) + ->addColumn('edit_by', 'integer', ['null' => true]) + ->create(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/lib/NocDisplayAgent/NocDisplayAgent.php b/lib/NocDisplayAgent/NocDisplayAgent.php new file mode 100644 index 000000000..a92a289b4 --- /dev/null +++ b/lib/NocDisplayAgent/NocDisplayAgent.php @@ -0,0 +1,102 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + $this->connectTimeout = $connectTimeout; + } + + public static function fromDisplay(array $display): self + { + return new self($display['ip_address'], $display['agent_port'] ?? 5000); + } + + private function request(string $method, string $endpoint, ?array $data = null): array + { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => "http://{$this->host}:{$this->port}{$endpoint}", + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, + CURLOPT_FOLLOWLOCATION => true, + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + if ($data) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + } + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + return ['success' => false, 'error' => $error, 'online' => false]; + } + + $result = json_decode($response, true) ?? []; + $result['http_code'] = $httpCode; + $result['success'] = $httpCode >= 200 && $httpCode < 300; + $result['online'] = true; + return $result; + } + + public function health(): array + { + return $this->request('GET', '/health'); + } + + public function status(): array + { + return $this->request('GET', '/status'); + } + + public function isOnline(): bool + { + $result = $this->health(); + return ($result['status'] ?? '') === 'ok'; + } + + public function setUrl(int $port, string $url): array + { + if ($port < 0 || $port > 1) return ['success' => false, 'error' => 'Port must be 0 or 1']; + return $this->request('POST', "/display/{$port}/url", ['url' => $url]); + } + + public function refresh(int $port): array + { + if ($port < 0 || $port > 1) return ['success' => false, 'error' => 'Port must be 0 or 1']; + return $this->request('POST', "/display/{$port}/refresh"); + } + + public function cecOn(int $port): array + { + if ($port < 0 || $port > 1) return ['success' => false, 'error' => 'Port must be 0 or 1']; + return $this->request('POST', "/cec/{$port}/on"); + } + + public function cecOff(int $port): array + { + if ($port < 0 || $port > 1) return ['success' => false, 'error' => 'Port must be 0 or 1']; + return $this->request('POST', "/cec/{$port}/off"); + } + + public function reboot(): array + { + return $this->request('POST', '/system/reboot'); + } +} diff --git a/public/css/views/RaspberryDisplay.css b/public/css/views/RaspberryDisplay.css index 9c01038cc..168b686b7 100644 --- a/public/css/views/RaspberryDisplay.css +++ b/public/css/views/RaspberryDisplay.css @@ -1,51 +1,745 @@ -[v-cloak] { - display: none !important; +/* NOC Display Manager - Modern Styling */ + +:root { + --noc-27-width: 140px; + --noc-42-width: 200px; + --noc-55-width: 260px; + --noc-65-width: 320px; + --noc-online: var(--tt-ok, #0f9d58); + --noc-offline: var(--tt-bad, #e03131); + --noc-warning: #f59f00; + --noc-drag-shadow: 0 12px 32px rgba(0, 83, 132, 0.2); } -.display-grid { - display: grid; - grid-template-columns: repeat(8, 11.25vw); - grid-row-gap: 20px; - width: 100vw; +/* Main Container */ +.noc-display-manager { + padding: 20px; + max-width: 1600px; + margin: 0 auto; } -.display { - background-color: #f8f9fa; - border: 2px solid #dee2e6; - border-radius: 4px; - overflow: hidden; - font-size: clamp(0.6rem, 0.8rem, 1.1rem); - display: grid; - grid-template-rows: repeat(3, 1fr); - justify-items: center; +/* Header */ +.noc-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; } -.display > *:nth-child(1) { - align-self: start; -} - -.display > *:nth-child(2) { - align-self: center; -} - -.display > *:nth-child(3) { - align-self: end; -} - -.small-27-inch { - grid-column: span 1; - margin: 0 0.37vw; - width: calc(10.5vw); - height: calc(10.5vw * 9 / 16) -} - -.big-42-inch { - grid-column: span 2; - margin: 0 0.37vw; - width: calc(21vw); - height: calc(21vw * 9 / 16) -} - -label { +.noc-title { + font-size: 24px; + font-weight: 700; + color: var(--tt-text-primary, #1a1a2e); margin: 0; -} \ No newline at end of file +} + +.noc-header-actions { + display: flex; + gap: 8px; +} + +/* Action Bar */ +.noc-action-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 24px; + padding: 12px 16px; + background: var(--tt-card, #fff); + border-radius: 12px; + border: 1px solid var(--tt-border, #e9ecef); + flex-wrap: wrap; +} + +.noc-search-input { + padding: 8px 12px; + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 8px; + font-size: 14px; + width: 250px; + background: var(--tt-card-2, #f8f9fa); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.noc-search-input:focus { + outline: none; + border-color: var(--tt-accent, #005384); + box-shadow: 0 0 0 3px rgba(0, 83, 132, 0.1); +} + +.noc-bulk-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* Loading & Empty States */ +.noc-loading { + display: flex; + flex-direction: column; + gap: 16px; +} + +.noc-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; + color: var(--tt-text-secondary, #6c757d); +} + +.noc-empty-state i { + font-size: 64px; + opacity: 0.3; + margin-bottom: 16px; +} + +.noc-empty-state h3 { + margin: 0 0 8px; + font-weight: 600; + color: var(--tt-text-primary, #1a1a2e); +} + +.noc-empty-state p { + margin: 0 0 20px; +} + +/* Groups */ +.noc-groups { + display: flex; + flex-direction: column; + gap: 16px; +} + +.noc-group { + background: var(--tt-card, #fff); + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 12px; + overflow: hidden; + transition: box-shadow 0.2s, border-color 0.2s; +} + +.noc-group.is-drop-target { + border-color: var(--tt-accent, #005384); + box-shadow: inset 0 0 0 2px var(--tt-accent, #005384); + background: rgba(0, 83, 132, 0.02); +} + +.noc-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--tt-card-2, #f8f9fa); + border-bottom: 1px solid var(--tt-border, #e9ecef); + cursor: pointer; + user-select: none; + transition: background 0.15s; +} + +.noc-group-header:hover { + background: var(--tt-card-3, #eef1f4); +} + +.noc-group-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 14px; + color: var(--tt-text-primary, #1a1a2e); +} + +.noc-group-title i { + font-size: 12px; + color: var(--tt-text-tertiary, #adb5bd); + transition: transform 0.2s; +} + +.noc-group-title i.rotated { + transform: rotate(90deg); +} + +.noc-group-count { + background: var(--tt-accent, #005384); + color: #fff; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} + +.noc-group-actions { + display: flex; + gap: 4px; +} + +.noc-group-body { + padding: 16px; + display: flex; + flex-wrap: wrap; + gap: 12px; + min-height: 80px; +} + +.noc-group-empty { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--tt-text-tertiary, #adb5bd); + font-size: 13px; + border: 2px dashed var(--tt-border, #e9ecef); + border-radius: 8px; +} + +/* Add Group Button */ +.noc-add-group { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + border: 2px dashed var(--tt-border, #e9ecef); + border-radius: 12px; + color: var(--tt-text-tertiary, #adb5bd); + font-size: 14px; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s; +} + +.noc-add-group:hover { + border-color: var(--tt-accent, #005384); + color: var(--tt-accent, #005384); + background: rgba(0, 83, 132, 0.02); +} + +/* Display Card */ +.noc-display-card { + background: var(--tt-card, #fff); + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + position: relative; + cursor: grab; +} + +/* Size Variations */ +.noc-display-card.size-27 { width: var(--noc-27-width); min-height: 120px; } +.noc-display-card.size-42 { width: var(--noc-42-width); min-height: 140px; } +.noc-display-card.size-55 { width: var(--noc-55-width); min-height: 160px; } +.noc-display-card.size-65 { width: var(--noc-65-width); min-height: 180px; } + +.noc-display-card:hover { + border-color: var(--tt-accent, #005384); + box-shadow: 0 4px 16px rgba(0, 83, 132, 0.1); + transform: translateY(-2px); +} + +.noc-display-card.is-dragging { + opacity: 0.6; + transform: scale(0.98); + box-shadow: var(--noc-drag-shadow); +} + +.noc-display-card.is-refreshing { + animation: noc-pulse 1.5s ease-in-out infinite; +} + +@keyframes noc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Drag Handle */ +.noc-drag-handle { + position: absolute; + top: 6px; + left: 6px; + padding: 2px 4px; + color: var(--tt-text-tertiary, #adb5bd); + opacity: 0; + cursor: grab; + transition: opacity 0.15s; + font-size: 10px; +} + +.noc-display-card:hover .noc-drag-handle { + opacity: 0.5; +} + +.noc-drag-handle:hover { + opacity: 1 !important; +} + +/* Card Header */ +.noc-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.noc-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.noc-status-dot.online { + background: var(--noc-online); + box-shadow: 0 0 8px var(--noc-online); +} + +.noc-status-dot.offline { + background: var(--noc-offline); + box-shadow: 0 0 8px var(--noc-offline); +} + +.noc-monitor-size { + font-size: 11px; + font-weight: 600; + color: var(--tt-text-tertiary, #adb5bd); + background: var(--tt-card-2, #f8f9fa); + padding: 2px 6px; + border-radius: 4px; +} + +/* Card Content */ +.noc-card-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.noc-display-label { + font-weight: 600; + font-size: 13px; + color: var(--tt-text-primary, #1a1a2e); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.noc-display-info { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.noc-hdmi-badge, +.noc-ip-badge { + font-size: 10px; + padding: 2px 5px; + border-radius: 4px; + background: var(--tt-card-2, #f8f9fa); + color: var(--tt-text-secondary, #6c757d); + font-family: var(--tt-mono, monospace); +} + +.noc-display-url { + font-size: 11px; + color: var(--tt-text-tertiary, #adb5bd); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + padding: 4px 0; + transition: color 0.15s; +} + +.noc-display-url:hover { + color: var(--tt-accent, #005384); +} + +.noc-url-input { + width: 100%; + padding: 4px 6px; + font-size: 11px; + border: 1px solid var(--tt-accent, #005384); + border-radius: 4px; + background: #fff; +} + +/* Metrics */ +.noc-metrics { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 8px; +} + +.noc-metric-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 5px; + border-radius: 4px; + font-size: 9px; + font-family: var(--tt-mono, monospace); + background: var(--tt-card-2, #f8f9fa); + border: 1px solid var(--tt-border, #e9ecef); + color: var(--tt-text-secondary, #6c757d); +} + +.noc-metric-chip i { + font-size: 8px; +} + +.noc-metric-chip.warn { + background: #fff8e6; + border-color: #ffe066; + color: #e67700; +} + +.noc-metric-chip.critical { + background: #fff5f5; + border-color: #ffc9c9; + color: #c92a2a; + animation: noc-temp-warning 2s ease-in-out infinite; +} + +@keyframes noc-temp-warning { + 0%, 100% { background: #fff5f5; } + 50% { background: #ffe3e3; } +} + +/* Actions */ +.noc-actions { + display: flex; + gap: 4px; + margin-top: 8px; + opacity: 0; + transition: opacity 0.15s; +} + +.noc-display-card:hover .noc-actions { + opacity: 1; +} + +.noc-action-btn { + padding: 4px 6px; + border-radius: 4px; + border: none; + background: var(--tt-card-2, #f8f9fa); + color: var(--tt-text-secondary, #6c757d); + cursor: pointer; + font-size: 11px; + transition: background 0.15s, color 0.15s; +} + +.noc-action-btn:hover { + background: var(--tt-accent, #005384); + color: #fff; +} + +.noc-action-btn.danger:hover { + background: var(--noc-offline); +} + +.noc-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Modal Form */ +.noc-modal-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.noc-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.noc-form-group label { + font-size: 12px; + font-weight: 600; + color: var(--tt-text-secondary, #6c757d); +} + +.noc-form-row { + display: flex; + gap: 12px; +} + +.noc-form-row .noc-form-group { + flex: 1; +} + +.noc-divider { + border: none; + border-top: 1px solid var(--tt-border, #e9ecef); + margin: 8px 0; +} + +/* Discovery Section */ +.noc-discover-section { + background: var(--tt-card-2, #f8f9fa); + padding: 16px; + border-radius: 8px; + margin-bottom: 8px; +} + +.noc-discover-section h4 { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + color: var(--tt-text-primary, #1a1a2e); +} + +.noc-discover-row { + display: flex; + gap: 8px; + align-items: center; +} + +.noc-discover-row input { + flex: 1; +} + +.noc-discovered-info { + margin-top: 12px; + padding: 12px; + background: #fff; + border-radius: 8px; + border: 1px solid var(--tt-border, #e9ecef); +} + +.noc-discovered-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; +} + +.noc-discovered-displays { + display: flex; + flex-direction: column; + gap: 6px; +} + +.noc-discovered-display { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + background: var(--tt-card-2, #f8f9fa); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} + +.noc-discovered-display:hover { + background: var(--tt-card-3, #eef1f4); +} + +.noc-url-preview { + font-size: 12px; + color: var(--tt-text-secondary, #6c757d); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.noc-discover-error { + margin-top: 12px; + padding: 10px; + background: #fff5f5; + border-radius: 6px; + color: #c92a2a; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Progress Overlay */ +.noc-progress-overlay { + position: fixed; + top: 80px; + right: 20px; + background: var(--tt-card, #fff); + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 1000; + min-width: 280px; +} + +.noc-progress-header { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 14px; + margin-bottom: 12px; + color: var(--tt-text-primary, #1a1a2e); +} + +.noc-progress-bar { + height: 6px; + background: var(--tt-card-2, #f8f9fa); + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; +} + +.noc-progress-fill { + height: 100%; + background: var(--tt-accent, #005384); + border-radius: 3px; + transition: width 0.3s ease; +} + +.noc-progress-text { + font-size: 12px; + color: var(--tt-text-secondary, #6c757d); +} + +/* Buttons */ +.tt-scope .ghost-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 8px; + background: var(--tt-card, #fff); + color: var(--tt-text-primary, #1a1a2e); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.tt-scope .ghost-btn:hover { + background: var(--tt-card-2, #f8f9fa); + border-color: var(--tt-accent, #005384); + color: var(--tt-accent, #005384); +} + +.tt-scope .ghost-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.tt-scope .ghost-btn.small { + padding: 4px 8px; + font-size: 12px; +} + +.tt-scope .ghost-btn.danger:hover { + border-color: var(--noc-offline); + color: var(--noc-offline); +} + +.tt-scope .primary-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 8px; + background: var(--tt-accent, #005384); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.tt-scope .primary-btn:hover { + background: var(--tt-accent-2, #1e88c9); +} + +.tt-scope .primary-btn:active { + transform: scale(0.98); +} + +.tt-scope .primary-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.tt-scope .primary-btn.danger { + background: var(--noc-offline); +} + +.tt-scope .primary-btn.danger:hover { + background: #c92a2a; +} + +/* Form Controls */ +.tt-scope .form-control { + padding: 8px 12px; + border: 1px solid var(--tt-border, #e9ecef); + border-radius: 8px; + font-size: 14px; + background: var(--tt-card, #fff); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.tt-scope .form-control:focus { + outline: none; + border-color: var(--tt-accent, #005384); + box-shadow: 0 0 0 3px rgba(0, 83, 132, 0.1); +} + +.tt-scope select.form-control { + cursor: pointer; +} + +/* Utility Classes */ +.mt-2 { margin-top: 8px; } +.mt-3 { margin-top: 12px; } +.mt-4 { margin-top: 16px; } +.text-muted { color: var(--tt-text-tertiary, #adb5bd); } +.small { font-size: 12px; } + +/* Responsive */ +@media (max-width: 768px) { + .noc-action-bar { + flex-direction: column; + align-items: stretch; + } + + .noc-search-input { + width: 100%; + } + + .noc-bulk-actions { + justify-content: center; + } + + .noc-display-card.size-27, + .noc-display-card.size-42, + .noc-display-card.size-55, + .noc-display-card.size-65 { + width: calc(50% - 6px); + min-height: auto; + } + + .noc-form-row { + flex-direction: column; + } + + .noc-form-row .noc-form-group { + width: 100% !important; + } +} diff --git a/public/js/pages/RaspberryDisplay/RaspberryDisplay.js b/public/js/pages/RaspberryDisplay/RaspberryDisplay.js deleted file mode 100644 index 14438b13e..000000000 --- a/public/js/pages/RaspberryDisplay/RaspberryDisplay.js +++ /dev/null @@ -1,147 +0,0 @@ -Vue.filter('cleanupURL', function (value) { - value = value.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "").split('/')[0]; - return value; -}) - -Vue.component('RaspberryDisplay', { - //language=Vue - template: ` -
- - -
-
-

8322 Studenzen NOC Displays

- -
- - - - -
-
- -
-
-
-
- - -
-
-
- {{ display['display_url'] | cleanupURL }} - -
- -
-
- - -
-
- -
- -
- - -
- - - | - - -
- - - -
-
- -
- -
-
-
-
- `, - - data() { - return { - loading: false, displaysURLEditMode: null, displays: null, window: window - } - }, - mounted() { - this.fetchDisplays().then() - }, methods: { - async rebootRaspberry(displayID) { - this.loading = true; - if (displayID === 'all') { - await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=rebootAll`); - } else { - await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=reboot`, { - params: { - displayID: displayID - } - }) - } - this.loading = false; - }, async displayPower(state) { - this.loading = true; - await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=displayPower`, { - params: { - state: state - } - }); - this.loading = false; - }, async fetchDisplays() { - this.loading = true; - const response = await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=getDisplays`); - this.displays = response.data.result; - this.loading = false; - Vue.nextTick(() => { - $('[data-toggle="tooltip"]').tooltip('dispose'); - $('[data-toggle="tooltip"]').tooltip(); - }); - }, enableDisplayURLEditMode(displayID) { - this.displaysURLEditMode = displayID; - const _this = this; - // wait for the DOM to update - Vue.nextTick(() => { - _this.$refs['displayURLEditInput'][0].focus(); - }); - }, disableDisplayURLEditMode(displayID, displayURL) { - this.displaysURLEditMode = null; - this.submitChanges(displayID, 'display_url', displayURL); - }, async submitChanges(displayID, field, value) { - this.loading = true; - await axios.get(`${window['TT_CONFIG']["BASE_URL"]}/api?do=change`, { - params: { - displayID: displayID, field: field, value: value, - } - }); - await this.fetchDisplays(); - this.loading = false; - } - }, - - -}) diff --git a/public/js/pages/RaspberryDisplay/RaspberryDisplayApp.js b/public/js/pages/RaspberryDisplay/RaspberryDisplayApp.js new file mode 100644 index 000000000..d05bf3241 --- /dev/null +++ b/public/js/pages/RaspberryDisplay/RaspberryDisplayApp.js @@ -0,0 +1,820 @@ +// NOC Display Manager - Vue 3 Component +const RaspberryDisplay = { + name: 'RaspberryDisplay', + template: ` +
+ +
+

NOC Display Manager

+
+ +
+
+ + +
+ +
+ + + + +
+
+ + +
+ +
+ + +
+ +

No Displays Configured

+

Add your first display to get started

+ +
+ + +
+
+ +
+
+ + {{ groupName }} + {{ displays.length }} +
+
+ +
+
+ + +
+
+ +
+ +
+ + +
+
+ {{ display.monitor_size }}" +
+ + +
+
{{ display.display_label }}
+
+ HDMI:{{ display.hdmi_port }} + {{ display.ip_address }} +
+
+ + +
+
+ + +
+ + + {{ displayStatuses[display.ip_address]?.temperature_c?.toFixed(1) || '--' }}°C + + + + {{ displayStatuses[display.ip_address]?.cpu_percent?.toFixed(0) || '--' }}% + +
+ + +
+ + + + +
+
+ + +
+ Drop displays here +
+
+
+ + +
+ + Add Group +
+
+ + + +
+ +
+

Discover Raspberry Pi

+
+ + + +
+
+
+ + {{ discoveredPi.hostname || 'Raspberry Pi' }} found +
+
+
+ HDMI:{{ disp.hdmi_port }} + {{ truncateUrl(disp.current_url) || 'No URL' }} +
+
+
+
+ + {{ discoverError }} +
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + +
+ +
+ + + +

Are you sure you want to delete {{ deleteTarget?.display_label }}?

+

This action cannot be undone.

+ +
+ + +
+
+ + Refreshing Displays +
+
+
+
+
+ {{ refreshProgress.current }} / {{ refreshProgress.total }} + - {{ refreshProgress.currentDisplay }} +
+
+
+ `, + data() { + return { + // Data + groupedDisplays: {}, + displayStatuses: {}, + availableGroups: [], + + // UI State + loading: true, + searchQuery: '', + collapsedGroups: {}, + + // Modals + showDisplayModal: false, + showAddGroupModal: false, + showDeleteModal: false, + editingDisplay: null, + deleteTarget: null, + + // Form Data + formData: this.getEmptyFormData(), + newGroupName: '', + newGroupNameInput: '', + saving: false, + deleting: false, + + // Discovery + discoverIp: '', + discoverPort: 5000, + discovering: false, + discoveredPi: null, + discoverError: '', + + // URL Editing + editingUrlId: null, + editUrlValue: '', + + // Drag and Drop + draggingDisplay: null, + dragOverGroup: null, + + // Bulk Operations + refreshingAll: false, + refreshProgress: { current: 0, total: 0, currentDisplay: '' }, + poweringAll: false, + rebootingAll: false, + refreshingDisplays: {}, + + // Polling + statusPollInterval: null + }; + }, + computed: { + filteredGroups() { + if (!this.searchQuery.trim()) { + return this.groupedDisplays; + } + const query = this.searchQuery.toLowerCase(); + const filtered = {}; + for (const [groupName, displays] of Object.entries(this.groupedDisplays)) { + const matchingDisplays = displays.filter(d => + d.display_label.toLowerCase().includes(query) || + d.ip_address.includes(query) || + d.hostname?.toLowerCase().includes(query) || + d.display_url?.toLowerCase().includes(query) + ); + if (matchingDisplays.length > 0) { + filtered[groupName] = matchingDisplays; + } + } + return filtered; + } + }, + methods: { + getEmptyFormData() { + return { + display_label: '', + hostname: '', + ip_address: '', + display_url: '', + group_name: '', + monitor_size: '27', + hdmi_port: 0, + agent_port: 5000, + custom_style: '' + }; + }, + + async loadDisplays() { + try { + const response = await axios.get(window.TT_CONFIG.BASE_URL + 'api', { + params: { do: 'getDisplays' } + }); + if (response.data.status === 'OK') { + this.groupedDisplays = response.data.result; + this.availableGroups = Object.keys(this.groupedDisplays); + } + } catch (error) { + console.error('Failed to load displays:', error); + } finally { + this.loading = false; + } + }, + + async loadStatuses() { + try { + const response = await axios.get(window.TT_CONFIG.BASE_URL + 'api', { + params: { do: 'getBatchStatus' } + }); + if (response.data.status === 'OK') { + this.displayStatuses = response.data.result; + } + } catch (error) { + console.error('Failed to load statuses:', error); + } + }, + + startStatusPolling() { + this.loadStatuses(); + this.statusPollInterval = setInterval(() => { + this.loadStatuses(); + }, 30000); + }, + + stopStatusPolling() { + if (this.statusPollInterval) { + clearInterval(this.statusPollInterval); + } + }, + + // Display Status + getStatusClass(display) { + const status = this.displayStatuses[display.ip_address]; + return status?.online ? 'online' : 'offline'; + }, + + getTempClass(status) { + if (!status?.temperature_c) return ''; + if (status.temperature_c >= 80) return 'critical'; + if (status.temperature_c >= 70) return 'warn'; + return ''; + }, + + truncateUrl(url) { + if (!url) return ''; + const cleanUrl = url.replace(/^https?:\/\/(www\.)?/, ''); + return cleanUrl.length > 30 ? cleanUrl.substring(0, 30) + '...' : cleanUrl; + }, + + // Group Management + toggleGroup(groupName) { + this.collapsedGroups[groupName] = !this.collapsedGroups[groupName]; + }, + + openAddGroupModal() { + this.newGroupNameInput = ''; + this.showAddGroupModal = true; + }, + + async createGroup() { + if (!this.newGroupNameInput.trim()) return; + this.availableGroups.push(this.newGroupNameInput.trim()); + if (!this.groupedDisplays[this.newGroupNameInput.trim()]) { + this.groupedDisplays[this.newGroupNameInput.trim()] = []; + } + this.showAddGroupModal = false; + }, + + // Display CRUD + openAddModal() { + this.editingDisplay = null; + this.formData = this.getEmptyFormData(); + this.discoveredPi = null; + this.discoverError = ''; + this.discoverIp = ''; + this.newGroupName = ''; + this.showDisplayModal = true; + }, + + openEditModal(display) { + this.editingDisplay = display; + this.formData = { ...display }; + this.showDisplayModal = true; + }, + + closeDisplayModal() { + this.showDisplayModal = false; + this.editingDisplay = null; + this.discoveredPi = null; + }, + + async saveDisplay() { + let groupName = this.formData.group_name; + if (groupName === '__new__') { + groupName = this.newGroupName.trim(); + if (!groupName) { + alert('Please enter a group name'); + return; + } + } + + if (!this.formData.display_label || !this.formData.ip_address || !groupName) { + alert('Please fill in all required fields'); + return; + } + + this.saving = true; + try { + const action = this.editingDisplay ? 'updateDisplay' : 'createDisplay'; + const params = { + do: action, + ...this.formData, + group_name: groupName + }; + if (this.editingDisplay) { + params.id = this.editingDisplay.id; + } + + const response = await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { params }); + if (response.data.status === 'OK' || response.data.status === 'success') { + this.closeDisplayModal(); + await this.loadDisplays(); + } else { + alert('Failed to save display'); + } + } catch (error) { + console.error('Failed to save display:', error); + alert('Failed to save display'); + } finally { + this.saving = false; + } + }, + + confirmDelete(display) { + this.deleteTarget = display; + this.showDeleteModal = true; + }, + + async deleteDisplay() { + if (!this.deleteTarget) return; + + this.deleting = true; + try { + const response = await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'deleteDisplay', id: this.deleteTarget.id } + }); + if (response.data.status === 'OK' || response.data.status === 'success') { + this.showDeleteModal = false; + this.deleteTarget = null; + await this.loadDisplays(); + } + } catch (error) { + console.error('Failed to delete display:', error); + } finally { + this.deleting = false; + } + }, + + // Discovery + async discoverPi() { + if (!this.discoverIp) return; + + this.discovering = true; + this.discoveredPi = null; + this.discoverError = ''; + + try { + const response = await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { + do: 'discoverPi', + ip_address: this.discoverIp, + agent_port: this.discoverPort + } + }); + if (response.data.status === 'OK' && response.data.result.online) { + this.discoveredPi = response.data.result; + this.formData.ip_address = this.discoverIp; + this.formData.agent_port = this.discoverPort; + this.formData.hostname = this.discoveredPi.hostname || ''; + } else { + this.discoverError = response.data.result?.error || 'Could not connect to Raspberry Pi'; + } + } catch (error) { + this.discoverError = 'Failed to discover Pi'; + } finally { + this.discovering = false; + } + }, + + selectDiscoveredDisplay(disp, idx) { + this.formData.hdmi_port = disp.hdmi_port; + this.formData.display_url = disp.current_url || ''; + this.formData.display_label = `${this.discoveredPi.hostname || 'Pi'} HDMI:${disp.hdmi_port}`; + }, + + // URL Editing + startEditUrl(display) { + this.editingUrlId = display.id; + this.editUrlValue = display.display_url || ''; + this.$nextTick(() => { + const input = this.$refs.urlInput; + if (input) { + if (Array.isArray(input)) { + input[0]?.focus(); + } else { + input.focus(); + } + } + }); + }, + + cancelEditUrl() { + this.editingUrlId = null; + this.editUrlValue = ''; + }, + + async saveUrl(display) { + if (this.editUrlValue === display.display_url) { + this.cancelEditUrl(); + return; + } + + try { + const response = await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { + do: 'setUrl', + id: display.id, + url: this.editUrlValue + } + }); + if (response.data.status === 'OK' || response.data.result?.success) { + display.display_url = this.editUrlValue; + } + } catch (error) { + console.error('Failed to set URL:', error); + } finally { + this.cancelEditUrl(); + } + }, + + // Display Actions + async refreshDisplay(display) { + this.refreshingDisplays[display.id] = true; + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'refreshDisplay', id: display.id } + }); + } catch (error) { + console.error('Failed to refresh display:', error); + } finally { + this.refreshingDisplays[display.id] = false; + } + }, + + async togglePower(display) { + const status = this.displayStatuses[display.ip_address]; + const state = status?.displays?.[display.hdmi_port]?.cec_state === 'on' ? 'off' : 'on'; + + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'cecPower', id: display.id, state } + }); + await this.loadStatuses(); + } catch (error) { + console.error('Failed to toggle power:', error); + } + }, + + // Bulk Operations + async refreshAllDisplays() { + const allDisplays = Object.values(this.groupedDisplays).flat(); + if (allDisplays.length === 0) return; + + this.refreshingAll = true; + this.refreshProgress = { current: 0, total: allDisplays.length, currentDisplay: '' }; + + for (const display of allDisplays) { + this.refreshProgress.currentDisplay = display.display_label; + this.refreshingDisplays[display.id] = true; + + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'refreshDisplay', id: display.id } + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + } catch (error) { + console.error('Failed to refresh:', display.display_label); + } + + this.refreshingDisplays[display.id] = false; + this.refreshProgress.current++; + } + + this.refreshingAll = false; + }, + + async refreshGroup(groupName) { + const displays = this.groupedDisplays[groupName] || []; + for (const display of displays) { + await this.refreshDisplay(display); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }, + + async powerAllDisplays(state) { + this.poweringAll = true; + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'powerAll', state } + }); + await this.loadStatuses(); + } catch (error) { + console.error('Failed to power all:', error); + } finally { + this.poweringAll = false; + } + }, + + async rebootAllPis() { + if (!confirm('Are you sure you want to reboot all Raspberry Pis?')) return; + + this.rebootingAll = true; + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { do: 'rebootAll' } + }); + } catch (error) { + console.error('Failed to reboot all:', error); + } finally { + this.rebootingAll = false; + } + }, + + // Drag and Drop + handleDragStart(event, display) { + this.draggingDisplay = display; + event.dataTransfer.effectAllowed = 'move'; + }, + + handleDragEnd() { + this.draggingDisplay = null; + this.dragOverGroup = null; + }, + + handleDragOver(event, groupName) { + this.dragOverGroup = groupName; + }, + + handleDragLeave() { + this.dragOverGroup = null; + }, + + async handleDrop(event, targetGroup) { + if (!this.draggingDisplay) return; + + const display = this.draggingDisplay; + const sourceGroup = display.group_name; + + if (sourceGroup === targetGroup) { + this.dragOverGroup = null; + return; + } + + // Update locally first for responsiveness + const sourceDisplays = this.groupedDisplays[sourceGroup]; + const idx = sourceDisplays.findIndex(d => d.id === display.id); + if (idx !== -1) { + sourceDisplays.splice(idx, 1); + } + + if (!this.groupedDisplays[targetGroup]) { + this.groupedDisplays[targetGroup] = []; + } + display.group_name = targetGroup; + this.groupedDisplays[targetGroup].push(display); + + // Persist to server + try { + await axios.post(window.TT_CONFIG.BASE_URL + 'api', null, { + params: { + do: 'updateDisplay', + id: display.id, + group_name: targetGroup + } + }); + } catch (error) { + console.error('Failed to update display group:', error); + await this.loadDisplays(); + } + + this.dragOverGroup = null; + } + }, + mounted() { + this.loadDisplays(); + this.startStatusPolling(); + }, + beforeUnmount() { + this.stopStatusPolling(); + } +}; + +// Register component +if (window.VueApp) { + window.VueApp.component('raspberry-display', RaspberryDisplay); +} else { + Vue.component('raspberry-display', RaspberryDisplay); +}