821 lines
33 KiB
JavaScript
821 lines
33 KiB
JavaScript
// 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);
|
|
}
|