Files
meshcore-bot/modules/web_viewer/templates/greeter.html
T

595 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}Greeter - MeshCore Bot Data Viewer{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-hand-sparkles"></i> Greeter Management
</h1>
</div>
</div>
<!-- Rollout Status -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<i class="fas fa-clock"></i> Onboarding Period Status
</div>
<div class="card-body">
<div id="rollout-status">
<div class="loading">Loading rollout status...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Greeter Settings -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="fas fa-cog"></i> Current Settings
</div>
<div class="card-body">
<div id="greeter-settings">
<div class="loading">Loading settings...</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="fas fa-comment"></i> Sample Greeting
</div>
<div class="card-body">
<div id="sample-greeting">
<div class="loading">Loading sample greeting...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Greeted Users -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-users"></i> Greeted Users</span>
<span class="badge bg-primary" id="total-greeted-badge">0</span>
</div>
<div class="card-body">
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input type="text" class="form-control" id="greeted-users-search"
placeholder="Search by username, channel, date, or last seen...">
<button class="btn btn-outline-secondary" type="button" id="clear-search-btn">
<i class="fas fa-times"></i> Clear
</button>
</div>
<small class="text-muted" id="search-results-count"></small>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Username</th>
<th>Channel</th>
<th>Greeted At</th>
<th>Last Seen</th>
<th>Rollout Marked</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="greeted-users-table">
<tr>
<td colspan="5" class="text-center">
<div class="loading">Loading greeted users...</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script nonce="{{ g.csp_nonce }}">
class GreeterManager {
constructor() {
this.greeterData = null;
this.allUsers = []; // Store all users for filtering
this.filteredUsers = []; // Store filtered users
this.initializeGreeter();
}
async initializeGreeter() {
await this.loadGreeterData();
this.setupEventHandlers();
this.renderRolloutStatus();
this.renderSettings();
this.renderSampleGreeting();
this.renderGreetedUsers();
// Auto-refresh every 30 seconds
setInterval(() => {
const searchInput = document.getElementById('greeted-users-search');
const currentSearch = searchInput ? searchInput.value : '';
this.loadGreeterData().then(() => {
this.renderRolloutStatus();
this.renderSettings();
this.renderSampleGreeting();
this.renderGreetedUsers();
// Reapply search filter if one was active
if (currentSearch) {
this.filterUsers(currentSearch);
}
});
}, 30000);
}
async loadGreeterData() {
try {
const response = await fetch('/api/greeter');
const data = await response.json();
if (data.error) {
this.showError('Failed to load greeter data: ' + data.error);
return;
}
this.greeterData = data;
} catch (error) {
console.error('Error loading greeter data:', error);
this.showError('Failed to load greeter data: ' + error.message);
}
}
setupEventHandlers() {
// End rollout button handler will be set up in renderRolloutStatus
// Search box handler
const searchInput = document.getElementById('greeted-users-search');
const clearBtn = document.getElementById('clear-search-btn');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
this.filterUsers(e.target.value);
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
searchInput.value = '';
this.filterUsers('');
});
}
}
renderRolloutStatus() {
const statusDiv = document.getElementById('rollout-status');
if (!this.greeterData) {
statusDiv.replaceChildren(this.makeTextElement('div', 'Loading...', 'loading'));
return;
}
if (!this.greeterData.enabled) {
statusDiv.replaceChildren(
this.makeTextElement('div', 'Greeter is currently disabled.', 'alert alert-warning'),
);
return;
}
if (this.greeterData.rollout_active) {
const timeRemaining = this.greeterData.time_remaining;
const timeStr = this.formatTimeRemaining(timeRemaining);
const endDate = new Date(this.greeterData.rollout_data.end_date);
const alert = document.createElement('div');
alert.className = 'alert alert-info';
alert.appendChild(this.makeTextElement('h5', 'Onboarding Period Active'));
this.appendLabeledParagraph(alert, 'Time Remaining', timeStr, 'mb-2');
this.appendLabeledParagraph(
alert,
'Started',
new Date(this.greeterData.rollout_data.started_at).toLocaleString(),
'mb-2',
);
this.appendLabeledParagraph(alert, 'Ends', endDate.toLocaleString(), 'mb-2');
this.appendLabeledParagraph(
alert,
'Duration',
`${this.greeterData.rollout_data.days} days`,
'mb-0',
);
const actions = document.createElement('div');
actions.className = 'mt-3';
const endButton = this.makeTextElement('button', 'End Onboarding Period', 'btn btn-danger');
endButton.type = 'button';
endButton.id = 'end-rollout-btn';
endButton.addEventListener('click', () => this.endRollout());
actions.appendChild(endButton);
statusDiv.replaceChildren(alert, actions);
} else {
statusDiv.replaceChildren(this.makeTextElement(
'div',
'No active onboarding period. New users will be greeted immediately.',
'alert alert-success',
));
}
}
renderSettings() {
const settingsDiv = document.getElementById('greeter-settings');
if (!this.greeterData) {
settingsDiv.replaceChildren(this.makeTextElement('div', 'Loading...', 'loading'));
return;
}
const settings = this.greeterData.settings;
const list = document.createElement('ul');
list.className = 'list-unstyled';
[
['Enabled', settings.enabled ? 'Yes' : 'No'],
['Rollout Days', settings.rollout_days],
['Per-Channel Greetings', settings.per_channel_greetings ? 'Yes' : 'No'],
['Include Mesh Info', settings.include_mesh_info ? 'Yes' : 'No'],
].forEach(([label, value]) => this.appendLabeledListItem(list, label, value));
settingsDiv.replaceChildren(list);
}
renderSampleGreeting() {
const greetingDiv = document.getElementById('sample-greeting');
if (!this.greeterData) {
greetingDiv.replaceChildren(this.makeTextElement('div', 'Loading...', 'loading'));
return;
}
const sample = this.makeTextElement(
'div',
this.greeterData.sample_greeting,
'alert alert-light greeter-sample-text',
);
greetingDiv.replaceChildren(sample);
}
renderGreetedUsers(usersToRender = null) {
const tableBody = document.getElementById('greeted-users-table');
const badge = document.getElementById('total-greeted-badge');
if (!this.greeterData) {
this.renderEmptyRow(tableBody, 'Loading...', 'text-center loading');
return;
}
// Store all users for filtering
this.allUsers = this.greeterData.greeted_users || [];
// Use provided users or all users
const users = usersToRender || this.allUsers;
if (badge) {
badge.textContent = this.allUsers.length || 0;
}
if (users.length === 0) {
const searchInput = document.getElementById('greeted-users-search');
const hasSearch = searchInput && searchInput.value.trim();
this.renderEmptyRow(
tableBody,
hasSearch ? 'No users match your search.' : 'No users have been greeted yet.',
'text-center text-muted',
);
return;
}
tableBody.replaceChildren();
users.forEach(user => {
// greeted_at is a SQLite timestamp string (ISO format like "2025-12-03 02:52:38")
// SQLite stores timestamps in UTC, but without timezone indicator
// JavaScript Date will interpret it as local time, so we need to be explicit
let greetedDate;
if (user.greeted_at && user.greeted_at.includes('T')) {
// Already has ISO format with T separator
greetedDate = new Date(user.greeted_at);
} else if (user.greeted_at) {
// SQLite format: "YYYY-MM-DD HH:MM:SS" - treat as UTC and add Z
greetedDate = new Date(user.greeted_at + 'Z');
} else {
greetedDate = new Date();
}
// last_seen is a Unix timestamp (seconds since epoch) from message_stats
// Convert to milliseconds for JavaScript Date constructor
// Unix timestamps are always UTC
let lastSeenDate = null;
if (user.last_seen) {
const timestamp = user.last_seen;
// message_stats stores timestamps as INTEGER (seconds since epoch)
// Convert to milliseconds
lastSeenDate = new Date(timestamp * 1000);
}
const row = document.createElement('tr');
const senderCell = document.createElement('td');
senderCell.appendChild(this.makeTextElement('code', user.sender_id));
row.appendChild(senderCell);
[
user.channel,
greetedDate.toLocaleString(),
lastSeenDate ? lastSeenDate.toLocaleString() : 'Never',
].forEach(value => row.appendChild(this.makeTextElement('td', value)));
const rolloutCell = document.createElement('td');
rolloutCell.appendChild(this.makeTextElement(
'span',
user.rollout_marked ? 'Rollout' : 'Manual',
`badge ${user.rollout_marked ? 'bg-warning' : 'bg-success'}`,
));
row.appendChild(rolloutCell);
const actionCell = document.createElement('td');
const button = this.makeTextElement('button', 'Mark Ungreeted', 'btn btn-sm btn-danger');
button.type = 'button';
button.addEventListener('click', () => this.ungreetUser(user.sender_id, user.channel));
actionCell.appendChild(button);
row.appendChild(actionCell);
tableBody.appendChild(row);
});
}
makeTextElement(tagName, value, className = '') {
const element = document.createElement(tagName);
element.className = className;
element.textContent = String(value ?? '');
return element;
}
appendLabeledParagraph(parent, label, value, className = '') {
const paragraph = document.createElement('p');
paragraph.className = className;
const strong = document.createElement('strong');
strong.textContent = `${label}:`;
paragraph.append(strong, document.createTextNode(` ${String(value ?? '')}`));
parent.appendChild(paragraph);
}
appendLabeledListItem(parent, label, value) {
const item = document.createElement('li');
const strong = document.createElement('strong');
strong.textContent = `${label}:`;
item.append(strong, document.createTextNode(` ${String(value ?? '')}`));
parent.appendChild(item);
}
renderEmptyRow(tableBody, message, className) {
const row = document.createElement('tr');
const cell = this.makeTextElement('td', message, className);
cell.colSpan = 6;
row.appendChild(cell);
tableBody.replaceChildren(row);
}
filterUsers(searchTerm) {
if (!this.allUsers || this.allUsers.length === 0) {
return;
}
const term = searchTerm.toLowerCase().trim();
if (!term) {
this.filteredUsers = this.allUsers;
this.renderGreetedUsers(this.allUsers);
this.updateSearchResultsCount(this.allUsers.length, this.allUsers.length);
return;
}
// Filter by username, channel, date, or last seen
this.filteredUsers = this.allUsers.filter(user => {
const senderId = (user.sender_id || '').toLowerCase();
const channel = (user.channel || '').toLowerCase();
const greetedAt = new Date(user.greeted_at).toLocaleString().toLowerCase();
const lastSeen = user.last_seen ? new Date(user.last_seen * 1000).toLocaleString().toLowerCase() : 'never';
return senderId.includes(term) ||
channel.includes(term) ||
greetedAt.includes(term) ||
lastSeen.includes(term);
});
this.renderGreetedUsers(this.filteredUsers);
this.updateSearchResultsCount(this.filteredUsers.length, this.allUsers.length);
}
updateSearchResultsCount(filtered, total) {
const countElement = document.getElementById('search-results-count');
if (countElement) {
if (filtered === total) {
countElement.textContent = '';
} else {
countElement.textContent = `Showing ${filtered} of ${total} users`;
}
}
}
formatTimeRemaining(timeRemaining) {
if (!timeRemaining) return 'N/A';
const parts = [];
if (timeRemaining.days > 0) parts.push(`${timeRemaining.days}d`);
if (timeRemaining.hours > 0) parts.push(`${timeRemaining.hours}h`);
if (timeRemaining.minutes > 0) parts.push(`${timeRemaining.minutes}m`);
if (timeRemaining.seconds > 0 && parts.length === 0) parts.push(`${timeRemaining.seconds}s`);
return parts.join(' ') || 'Less than 1 minute';
}
async endRollout() {
if (!confirm('Are you sure you want to end the onboarding period? This will allow new users to be greeted immediately.')) {
return;
}
try {
const response = await fetch('/api/greeter/end-rollout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.success) {
this.showSuccess(data.message || 'Onboarding period ended successfully');
// Reload data
await this.loadGreeterData();
this.renderRolloutStatus();
} else {
this.showError(data.error || 'Failed to end onboarding period');
}
} catch (error) {
console.error('Error ending rollout:', error);
this.showError('Failed to end onboarding period: ' + error.message);
}
}
async ungreetUser(senderId, channel) {
const channelText = channel === '(global)' ? 'globally' : `on channel ${channel}`;
if (!confirm(`Are you sure you want to mark ${senderId} as ungreeted ${channelText}? They will be greeted again on their next message.`)) {
return;
}
try {
const response = await fetch('/api/greeter/ungreet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
sender_id: senderId,
channel: channel === '(global)' ? null : channel
})
});
const data = await response.json();
if (data.success) {
this.showSuccess(data.message || 'User marked as ungreeted');
// Reload data
const searchInput = document.getElementById('greeted-users-search');
const currentSearch = searchInput ? searchInput.value : '';
await this.loadGreeterData();
this.renderGreetedUsers();
// Reapply search filter if one was active
if (currentSearch) {
this.filterUsers(currentSearch);
}
} else {
this.showError(data.error || 'Failed to mark user as ungreeted');
}
} catch (error) {
console.error('Error ungreeting user:', error);
this.showError('Failed to mark user as ungreeted: ' + error.message);
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showError(message) {
if (window.connectionManager) {
window.connectionManager.showNotification(message, 'danger');
} else {
alert('Error: ' + message);
}
}
showSuccess(message) {
if (window.connectionManager) {
window.connectionManager.showNotification(message, 'success');
} else {
alert('Success: ' + message);
}
}
}
// Initialize greeter manager when page loads
document.addEventListener('DOMContentLoaded', () => {
window.greeterManager = new GreeterManager();
});
</script>
<style>
.greeter-sample-text {
white-space: pre-wrap;
font-family: monospace;
}
/* Dark mode table styling with better contrast - matching contacts page */
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
}
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
background-color: #2d2d2d; /* Darker gray (card-bg) */
}
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
}
[data-theme="dark"] .table-dark {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="dark"] .table-dark th {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .table td {
border-color: var(--border-color);
color: var(--text-color);
}
/* Ensure all text in table cells is light in dark mode */
[data-theme="dark"] .table td,
[data-theme="dark"] .table td * {
color: var(--text-color) !important;
}
/* Ensure small text is also light */
[data-theme="dark"] .table td small {
color: var(--text-muted) !important;
}
/* Code elements in tables should also be light */
[data-theme="dark"] .table td code {
color: var(--text-color) !important;
background-color: var(--bg-tertiary) !important;
}
</style>
{% endblock %}