Files
meshcore-bot/modules/web_viewer/templates/greeter.html
T
agessaman ebdc44cd90 fix(web_viewer): status and accent text passes contrast in both themes
Bootstrap's text-success, text-warning, text-info and text-danger are fill tones, so used as text they failed contrast (amber on white was 1.6:1, info 2.0:1), and nothing adjusted them for dark mode. base.html now defines per-theme status text tokens that pass 4.5:1 on every surface tier, and the text utilities, text-muted, form-text, code, links and the outline buttons resolve through them. Info and warning badges carry dark text. Ink Muted moves a step darker so it holds on paper-sunk.

The dark message bubbles on Realtime were solid navy, forest green and purple, which overpowered the page and dropped the sender names to 1.7:1. They are now low-alpha tints of the signal and lamp hues over the card, like the dashboard delta chips, and the error bubble is red in both themes instead of purple in dark.

Also: the light log levels and level toggles move to tones that pass on the log well, the dark ERROR toggle uses dark text, Realtime drops its local orange text-warning and its opacity on message times, and the four copies of the dark .table td * rule no longer force body text color onto badges and buttons. DESIGN.md records the tokens and the rule that a fill hue never goes on text directly.
2026-10-17 17:46:51 -07:00

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" aria-hidden="true"></i>
</span>
<input type="text" class="form-control" id="greeted-users-search" aria-label="Search greeted users" aria-describedby="search-results-count"
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);
}
/* Table cells take the light text color in dark mode; children with their
own color (badges, buttons, status text) keep it. */
[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 %}