raspberrydisplay v2 init
This commit is contained in:
@@ -1,11 +1,6 @@
|
||||
<?php
|
||||
|
||||
use phpseclib3\Net\SSH2;
|
||||
|
||||
class RaspberryDisplayController extends mfBaseController {
|
||||
private int $port = 22;
|
||||
private string $username = XINON_RASPBERRY_DISPLAY_SSH_USER;
|
||||
private string $password = XINON_RASPBERRY_DISPLAY_SSH_PASS;
|
||||
|
||||
protected function init(): void {
|
||||
$me = new User();
|
||||
@@ -15,161 +10,237 @@ class RaspberryDisplayController extends mfBaseController {
|
||||
}
|
||||
|
||||
protected function apiAction() {
|
||||
$do = $this->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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class RevampRaspberryDisplayTable extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if($this->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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
102
lib/NocDisplayAgent/NocDisplayAgent.php
Normal file
102
lib/NocDisplayAgent/NocDisplayAgent.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
class NocDisplayAgent
|
||||
{
|
||||
private string $host;
|
||||
private int $port;
|
||||
private int $timeout;
|
||||
private int $connectTimeout;
|
||||
|
||||
public function __construct(string $host, int $port = 5000, int $timeout = 10, int $connectTimeout = 5)
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="card">
|
||||
<tt-loader v-if="loading"></tt-loader>
|
||||
|
||||
<div class="p-2">
|
||||
<div style="display: grid; grid-template-columns: auto auto; justify-items: center;">
|
||||
<h3 style="justify-self: start;">8322 Studenzen NOC Displays</h3>
|
||||
<!-- Add turn on and turn off button here -->
|
||||
<div>
|
||||
<button class="btn btn-primary" @click="fetchDisplays">Refresh</button>
|
||||
<button class="btn btn-warning" @click="rebootRaspberry('all')">Reboot</button>
|
||||
<button class="btn btn-success" @click="displayPower('on')">Turn on</button>
|
||||
<button class="btn btn-danger" @click="displayPower('off')">Turn off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="display-grid">
|
||||
<div v-for="display in displays" :key="display.id"
|
||||
:class="['display', display['display_label'].includes('-B-') ? 'big-42-inch' : 'small-27-inch']"
|
||||
:style="display['custom_style']" style="">
|
||||
<div
|
||||
style="display: grid; grid-template-columns: max-content auto max-content; justify-items: center;width:100%; padding: 0 2px">
|
||||
<div>
|
||||
<!-- FONT AWESOME ONLINE GREEN CIRCLE -->
|
||||
<i class="fas fa-circle" data-toggle="tooltip" title="ONLINE" style="color: green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div @click.prevent="enableDisplayURLEditMode(display.id)" style="cursor: pointer">
|
||||
<span v-if="displaysURLEditMode !== display.id">{{ display['display_url'] | cleanupURL }}</span>
|
||||
<input v-else-if="displaysURLEditMode === display.id"
|
||||
v-model="display['display_url']"
|
||||
@keyup.enter="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
@blur="disableDisplayURLEditMode(display.id, display['display_url'])"
|
||||
ref="displayURLEditInput"
|
||||
class="form-control"
|
||||
type="text">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="cursor: pointer">
|
||||
<!-- FONT AWESOME REBOOT ICON -->
|
||||
<i class="fas fa-red fa-sync-alt" data-toggle="tooltip" title="Reboot this Raspberry"
|
||||
@click="rebootRaspberry(display.id)"
|
||||
style="color: green"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Checkbox for Auto Refresh Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="\`Auto refresh is \${display['auto_refresh_enabled'] ? 'enabled' : 'disabled'}.\`">
|
||||
<input type="checkbox" :id="'auto_refresh_enabled_checkbox_' + display.id"
|
||||
v-model="display['auto_refresh_enabled']"
|
||||
@change="submitChanges(display.id, 'auto_refresh_enabled', display['auto_refresh_enabled'])">
|
||||
<label :for="'auto_refresh_enabled_checkbox_' + display.id">ARF</label>
|
||||
</div>
|
||||
|
||||
<!-- This will only display if both are true, consider adjusting logic as needed -->
|
||||
<span style="margin: 0 4px"> | </span>
|
||||
|
||||
<!-- Checkbox for Margin Hotfix Enabled -->
|
||||
<div style="display: inline-block" data-toggle="tooltip"
|
||||
:title="\`Margin Hotfix is \${display['margin_hot_fix_enabled'] ? 'enabled' : 'disabled'}.\`">
|
||||
|
||||
<input type="checkbox" :id="'margin_hot_fix_enabled_checkbox_' + display.id"
|
||||
v-model="display['margin_hot_fix_enabled']"
|
||||
@change="submitChanges(display.id, 'margin_hot_fix_enabled', display['margin_hot_fix_enabled'])">
|
||||
<label :for="'margin_hot_fix_enabled_checkbox_' + display.id">MHF</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-text="display['display_label']"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
})
|
||||
820
public/js/pages/RaspberryDisplay/RaspberryDisplayApp.js
Normal file
820
public/js/pages/RaspberryDisplay/RaspberryDisplayApp.js
Normal file
@@ -0,0 +1,820 @@
|
||||
// NOC Display Manager - Vue 3 Component
|
||||
const RaspberryDisplay = {
|
||||
name: 'RaspberryDisplay',
|
||||
template: `
|
||||
<div class="tt-scope noc-display-manager">
|
||||
<!-- Header -->
|
||||
<div class="noc-header">
|
||||
<h1 class="noc-title">NOC Display Manager</h1>
|
||||
<div class="noc-header-actions">
|
||||
<button class="ghost-btn" @click="openAddModal" title="Add Display">
|
||||
<i class="fa-duotone fa-plus"></i>
|
||||
<span>Add Display</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="noc-action-bar">
|
||||
<input
|
||||
type="text"
|
||||
class="noc-search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search displays..."
|
||||
/>
|
||||
<div class="noc-bulk-actions">
|
||||
<button class="ghost-btn" @click="refreshAllDisplays" :disabled="refreshingAll" title="Refresh All">
|
||||
<i class="fa-duotone fa-arrows-rotate" :class="{ 'fa-spin': refreshingAll }"></i>
|
||||
<span>Refresh All</span>
|
||||
</button>
|
||||
<button class="ghost-btn" @click="powerAllDisplays('on')" :disabled="poweringAll" title="Power On All">
|
||||
<i class="fa-duotone fa-power-off"></i>
|
||||
<span>Power On</span>
|
||||
</button>
|
||||
<button class="ghost-btn" @click="powerAllDisplays('off')" :disabled="poweringAll" title="Power Off All">
|
||||
<i class="fa-duotone fa-moon"></i>
|
||||
<span>Power Off</span>
|
||||
</button>
|
||||
<button class="ghost-btn danger" @click="rebootAllPis" :disabled="rebootingAll" title="Reboot All">
|
||||
<i class="fa-duotone fa-rotate"></i>
|
||||
<span>Reboot All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="noc-loading">
|
||||
<tt-skeleton v-for="i in 3" :key="i" width="100%" height="200px" style="margin-bottom: 16px; border-radius: 12px;" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="Object.keys(groupedDisplays).length === 0" class="noc-empty-state">
|
||||
<i class="fa-duotone fa-display-slash"></i>
|
||||
<h3>No Displays Configured</h3>
|
||||
<p>Add your first display to get started</p>
|
||||
<button class="primary-btn" @click="openAddModal">
|
||||
<i class="fa-duotone fa-plus"></i>
|
||||
Add Display
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div v-else class="noc-groups">
|
||||
<div
|
||||
v-for="(displays, groupName) in filteredGroups"
|
||||
:key="groupName"
|
||||
class="noc-group"
|
||||
:class="{ 'is-drop-target': dragOverGroup === groupName }"
|
||||
@dragover.prevent="handleDragOver($event, groupName)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop($event, groupName)"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<div class="noc-group-header" @click="toggleGroup(groupName)">
|
||||
<div class="noc-group-title">
|
||||
<i class="fa-duotone fa-chevron-right" :class="{ 'rotated': !collapsedGroups[groupName] }"></i>
|
||||
<span>{{ groupName }}</span>
|
||||
<span class="noc-group-count">{{ displays.length }}</span>
|
||||
</div>
|
||||
<div class="noc-group-actions" @click.stop>
|
||||
<button class="ghost-btn small" @click="refreshGroup(groupName)" title="Refresh Group">
|
||||
<i class="fa-duotone fa-arrows-rotate"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Body -->
|
||||
<div class="noc-group-body" v-show="!collapsedGroups[groupName]">
|
||||
<div
|
||||
v-for="display in displays"
|
||||
:key="display.id"
|
||||
class="noc-display-card"
|
||||
:class="[
|
||||
'size-' + display.monitor_size,
|
||||
{ 'is-refreshing': refreshingDisplays[display.id] },
|
||||
{ 'is-dragging': draggingDisplay?.id === display.id }
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, display)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="noc-drag-handle">
|
||||
<i class="fa-solid fa-grip-vertical"></i>
|
||||
</div>
|
||||
|
||||
<!-- Card Header -->
|
||||
<div class="noc-card-header">
|
||||
<div class="noc-status-dot" :class="getStatusClass(display)"></div>
|
||||
<span class="noc-monitor-size">{{ display.monitor_size }}"</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="noc-card-content">
|
||||
<div class="noc-display-label">{{ display.display_label }}</div>
|
||||
<div class="noc-display-info">
|
||||
<span class="noc-hdmi-badge">HDMI:{{ display.hdmi_port }}</span>
|
||||
<span class="noc-ip-badge" :title="display.ip_address">{{ display.ip_address }}</span>
|
||||
</div>
|
||||
<div class="noc-display-url" @click="startEditUrl(display)" :title="display.display_url">
|
||||
<template v-if="editingUrlId === display.id">
|
||||
<input
|
||||
type="text"
|
||||
v-model="editUrlValue"
|
||||
@keyup.enter="saveUrl(display)"
|
||||
@keyup.escape="cancelEditUrl"
|
||||
@blur="saveUrl(display)"
|
||||
ref="urlInput"
|
||||
class="noc-url-input"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ truncateUrl(display.display_url) || 'Click to set URL' }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics -->
|
||||
<div class="noc-metrics" v-if="displayStatuses[display.ip_address]?.online">
|
||||
<span class="noc-metric-chip" :class="getTempClass(displayStatuses[display.ip_address])">
|
||||
<i class="fa-solid fa-temperature-half"></i>
|
||||
{{ displayStatuses[display.ip_address]?.temperature_c?.toFixed(1) || '--' }}°C
|
||||
</span>
|
||||
<span class="noc-metric-chip">
|
||||
<i class="fa-solid fa-microchip"></i>
|
||||
{{ displayStatuses[display.ip_address]?.cpu_percent?.toFixed(0) || '--' }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="noc-actions">
|
||||
<button class="noc-action-btn" @click="openEditModal(display)" title="Edit">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</button>
|
||||
<button class="noc-action-btn" @click="refreshDisplay(display)" :disabled="refreshingDisplays[display.id]" title="Refresh">
|
||||
<i class="fa-solid fa-arrows-rotate" :class="{ 'fa-spin': refreshingDisplays[display.id] }"></i>
|
||||
</button>
|
||||
<button class="noc-action-btn" @click="togglePower(display)" title="Power">
|
||||
<i class="fa-solid fa-power-off"></i>
|
||||
</button>
|
||||
<button class="noc-action-btn danger" @click="confirmDelete(display)" title="Delete">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Group -->
|
||||
<div v-if="displays.length === 0" class="noc-group-empty">
|
||||
Drop displays here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Group Button -->
|
||||
<div class="noc-add-group" @click="openAddGroupModal">
|
||||
<i class="fa-duotone fa-plus"></i>
|
||||
<span>Add Group</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Display Modal -->
|
||||
<tt-dialog :show="showDisplayModal" :title="editingDisplay ? 'Edit Display' : 'Add Display'" @close="closeDisplayModal" size="normal">
|
||||
<div class="noc-modal-form">
|
||||
<!-- Discover Pi Section (only for new displays) -->
|
||||
<div v-if="!editingDisplay" class="noc-discover-section">
|
||||
<h4>Discover Raspberry Pi</h4>
|
||||
<div class="noc-discover-row">
|
||||
<input type="text" v-model="discoverIp" placeholder="IP Address (e.g. 192.168.1.100)" class="form-control" />
|
||||
<input type="number" v-model="discoverPort" placeholder="Port" class="form-control" style="width: 100px;" />
|
||||
<button class="ghost-btn" @click="discoverPi" :disabled="discovering">
|
||||
<i class="fa-duotone fa-radar" :class="{ 'fa-spin': discovering }"></i>
|
||||
Discover
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="discoveredPi" class="noc-discovered-info">
|
||||
<div class="noc-discovered-header">
|
||||
<i class="fa-duotone fa-check-circle" style="color: var(--tt-ok);"></i>
|
||||
<span>{{ discoveredPi.hostname || 'Raspberry Pi' }} found</span>
|
||||
</div>
|
||||
<div class="noc-discovered-displays">
|
||||
<div v-for="(disp, idx) in discoveredPi.displays" :key="idx" class="noc-discovered-display" @click="selectDiscoveredDisplay(disp, idx)">
|
||||
<span class="noc-hdmi-badge">HDMI:{{ disp.hdmi_port }}</span>
|
||||
<span class="noc-url-preview">{{ truncateUrl(disp.current_url) || 'No URL' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="discoverError" class="noc-discover-error">
|
||||
<i class="fa-duotone fa-exclamation-triangle"></i>
|
||||
{{ discoverError }}
|
||||
</div>
|
||||
<hr class="noc-divider" />
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="noc-form-group">
|
||||
<label>Display Label *</label>
|
||||
<input type="text" v-model="formData.display_label" class="form-control" placeholder="e.g. NOC Monitor 1" />
|
||||
</div>
|
||||
<div class="noc-form-row">
|
||||
<div class="noc-form-group">
|
||||
<label>IP Address *</label>
|
||||
<input type="text" v-model="formData.ip_address" class="form-control" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div class="noc-form-group" style="width: 120px;">
|
||||
<label>Agent Port</label>
|
||||
<input type="number" v-model="formData.agent_port" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="noc-form-group">
|
||||
<label>Hostname</label>
|
||||
<input type="text" v-model="formData.hostname" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="noc-form-row">
|
||||
<div class="noc-form-group">
|
||||
<label>Group *</label>
|
||||
<select v-model="formData.group_name" class="form-control">
|
||||
<option v-for="group in availableGroups" :key="group" :value="group">{{ group }}</option>
|
||||
<option value="__new__">+ New Group...</option>
|
||||
</select>
|
||||
<input v-if="formData.group_name === '__new__'" type="text" v-model="newGroupName" class="form-control mt-2" placeholder="Enter new group name" />
|
||||
</div>
|
||||
<div class="noc-form-group" style="width: 120px;">
|
||||
<label>HDMI Port</label>
|
||||
<select v-model="formData.hdmi_port" class="form-control">
|
||||
<option :value="0">HDMI:0</option>
|
||||
<option :value="1">HDMI:1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="noc-form-group">
|
||||
<label>Monitor Size</label>
|
||||
<select v-model="formData.monitor_size" class="form-control">
|
||||
<option value="27">27"</option>
|
||||
<option value="42">42"</option>
|
||||
<option value="55">55"</option>
|
||||
<option value="65">65"</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="noc-form-group">
|
||||
<label>Display URL</label>
|
||||
<input type="text" v-model="formData.display_url" class="form-control" placeholder="https://example.com/dashboard" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="ghost-btn" @click="closeDisplayModal">Cancel</button>
|
||||
<button class="primary-btn" @click="saveDisplay" :disabled="saving">
|
||||
<i v-if="saving" class="fa-duotone fa-spinner fa-spin"></i>
|
||||
{{ editingDisplay ? 'Save Changes' : 'Add Display' }}
|
||||
</button>
|
||||
</template>
|
||||
</tt-dialog>
|
||||
|
||||
<!-- Add Group Modal -->
|
||||
<tt-dialog :show="showAddGroupModal" title="Add New Group" @close="showAddGroupModal = false">
|
||||
<div class="noc-form-group">
|
||||
<label>Group Name</label>
|
||||
<input type="text" v-model="newGroupNameInput" class="form-control" placeholder="e.g. Conference Room" @keyup.enter="createGroup" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="ghost-btn" @click="showAddGroupModal = false">Cancel</button>
|
||||
<button class="primary-btn" @click="createGroup" :disabled="!newGroupNameInput.trim()">Create Group</button>
|
||||
</template>
|
||||
</tt-dialog>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<tt-dialog :show="showDeleteModal" title="Delete Display" @close="showDeleteModal = false">
|
||||
<p>Are you sure you want to delete <strong>{{ deleteTarget?.display_label }}</strong>?</p>
|
||||
<p class="text-muted small">This action cannot be undone.</p>
|
||||
<template #footer>
|
||||
<button class="ghost-btn" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="primary-btn danger" @click="deleteDisplay" :disabled="deleting">
|
||||
<i v-if="deleting" class="fa-duotone fa-spinner fa-spin"></i>
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</tt-dialog>
|
||||
|
||||
<!-- Sequential Refresh Progress -->
|
||||
<div v-if="refreshingAll && refreshProgress.total > 0" class="noc-progress-overlay">
|
||||
<div class="noc-progress-header">
|
||||
<i class="fa-duotone fa-arrows-rotate fa-spin"></i>
|
||||
<span>Refreshing Displays</span>
|
||||
</div>
|
||||
<div class="noc-progress-bar">
|
||||
<div class="noc-progress-fill" :style="{ width: (refreshProgress.current / refreshProgress.total * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="noc-progress-text">
|
||||
{{ refreshProgress.current }} / {{ refreshProgress.total }}
|
||||
<span v-if="refreshProgress.currentDisplay"> - {{ refreshProgress.currentDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user