mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-03 05:55:41 +00:00
542 lines
20 KiB
HTML
542 lines
20 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>
|
|
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.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
if (!this.greeterData.enabled) {
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i> Greeter is currently disabled.
|
|
</div>
|
|
`;
|
|
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);
|
|
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<h5><i class="fas fa-hourglass-half"></i> Onboarding Period Active</h5>
|
|
<p class="mb-2"><strong>Time Remaining:</strong> <span class="badge bg-primary" style="font-size: 1.1em;">${timeStr}</span></p>
|
|
<p class="mb-2"><strong>Started:</strong> ${new Date(this.greeterData.rollout_data.started_at).toLocaleString()}</p>
|
|
<p class="mb-2"><strong>Ends:</strong> ${endDate.toLocaleString()}</p>
|
|
<p class="mb-0"><strong>Duration:</strong> ${this.greeterData.rollout_data.days} days</p>
|
|
</div>
|
|
<div class="mt-3">
|
|
<button class="btn btn-danger" id="end-rollout-btn">
|
|
<i class="fas fa-stop-circle"></i> End Onboarding Period
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Setup end rollout button
|
|
document.getElementById('end-rollout-btn').addEventListener('click', () => {
|
|
this.endRollout();
|
|
});
|
|
} else {
|
|
statusDiv.innerHTML = `
|
|
<div class="alert alert-success">
|
|
<i class="fas fa-check-circle"></i> No active onboarding period. New users will be greeted immediately.
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
renderSettings() {
|
|
const settingsDiv = document.getElementById('greeter-settings');
|
|
|
|
if (!this.greeterData) {
|
|
settingsDiv.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
const settings = this.greeterData.settings;
|
|
settingsDiv.innerHTML = `
|
|
<ul class="list-unstyled">
|
|
<li><strong>Enabled:</strong> <span class="badge bg-${settings.enabled ? 'success' : 'secondary'}">${settings.enabled ? 'Yes' : 'No'}</span></li>
|
|
<li><strong>Rollout Days:</strong> ${settings.rollout_days}</li>
|
|
<li><strong>Per-Channel Greetings:</strong> <span class="badge bg-${settings.per_channel_greetings ? 'info' : 'secondary'}">${settings.per_channel_greetings ? 'Yes' : 'No'}</span></li>
|
|
<li><strong>Include Mesh Info:</strong> <span class="badge bg-${settings.include_mesh_info ? 'info' : 'secondary'}">${settings.include_mesh_info ? 'Yes' : 'No'}</span></li>
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
renderSampleGreeting() {
|
|
const greetingDiv = document.getElementById('sample-greeting');
|
|
|
|
if (!this.greeterData) {
|
|
greetingDiv.innerHTML = '<div class="loading">Loading...</div>';
|
|
return;
|
|
}
|
|
|
|
greetingDiv.innerHTML = `
|
|
<div class="alert alert-light" style="white-space: pre-wrap; font-family: monospace;">
|
|
${this.escapeHtml(this.greeterData.sample_greeting)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderGreetedUsers(usersToRender = null) {
|
|
const tableBody = document.getElementById('greeted-users-table');
|
|
const badge = document.getElementById('total-greeted-badge');
|
|
|
|
if (!this.greeterData) {
|
|
tableBody.innerHTML = '<tr><td colspan="6" class="text-center"><div class="loading">Loading...</div></td></tr>';
|
|
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();
|
|
tableBody.innerHTML = `<tr><td colspan="6" class="text-center text-muted">${hasSearch ? 'No users match your search.' : 'No users have been greeted yet.'}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = users.map(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 lastSeenStr = lastSeenDate ? lastSeenDate.toLocaleString() : '<span class="text-muted">Never</span>';
|
|
const rolloutBadge = user.rollout_marked
|
|
? '<span class="badge bg-warning">Rollout</span>'
|
|
: '<span class="badge bg-success">Manual</span>';
|
|
|
|
return `
|
|
<tr>
|
|
<td><code>${this.escapeHtml(user.sender_id)}</code></td>
|
|
<td>${this.escapeHtml(user.channel)}</td>
|
|
<td>${greetedDate.toLocaleString()}</td>
|
|
<td>${lastSeenStr}</td>
|
|
<td>${rolloutBadge}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-danger" onclick="greeterManager.ungreetUser('${this.escapeHtml(user.sender_id)}', '${this.escapeHtml(user.channel)}')">
|
|
<i class="fas fa-undo"></i> Mark Ungreeted
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
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'
|
|
}
|
|
});
|
|
|
|
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'
|
|
},
|
|
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>
|
|
/* 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 %}
|
|
|