raspberrydisplay v2 init

This commit is contained in:
Luca Haid
2026-02-03 15:38:55 +01:00
parent 10ecfab41d
commit 0cdb2140a9
7 changed files with 2065 additions and 341 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
},
})

View 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);
}