mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-03 17:29:42 +00:00
595 lines
22 KiB
HTML
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 %}
|