Things are looking good. Now have a tools and setting pop up thing.

This commit is contained in:
Scott Idem
2025-12-15 18:07:41 -05:00
parent e16a28cc29
commit a7bf03e449
2 changed files with 295 additions and 96 deletions

View File

@@ -99,3 +99,35 @@ This pattern isolates API logic from data shaping logic, making the code more mo
- The `static/idaa_novi_iframe_jitsi_meeting.html` file acts as a bridge, taking Novi-templated user variables (`<%=Novi.User.*%>`) and passing them as URL parameters to the Svelte application (`/idaa/video_conferences`).
- **User Feedback:** An attempt to simplify this bridge by removing a client-side `fetch` call for user details was reverted by the user. This indicates that the original, more complex logic (which constructs `full_name` from `FirstName` and `LastName`) is necessary. This is an important lesson in not over-simplifying third-party integration code without full context.
- The bridge was updated to include an interactive UI for setting Jitsi parameters (e.g., sound settings, moderator settings) and dynamically rebuilding the iframe `src`.
---
## Jitsi "God Mode" Live Stats Architecture (2025-12-15)
### Goal
For the IDAA client, create an administrative dashboard ("god mode") to monitor live Jitsi meetings—including meeting duration, participant lists, and moderator roles—without needing to join the meetings directly. This is primarily for ensuring meetings are running correctly and for data collection.
### Architectural Decision
A purely client-side approach using the Jitsi External API is not feasible, as it requires being a participant in the meeting. A server-side solution is necessary.
After considering a log-parsing approach, the following hybrid architecture was chosen as the most robust and scalable solution.
1. **Server-Side XMPP Bot (Stats Collector):**
* A new, lightweight backend service will be created (likely in Python or Node.js).
* This service will act as a bot, using special credentials to connect to the Jitsi instance's main XMPP server (Prosody).
* The bot will automatically and invisibly join the Multi-User Chat (MUC) for each active conference. It will not process any audio or video, only presence and metadata.
* By listening to real-time XMPP events, it will track participant joins, leaves, and role changes.
2. **Database Integration:**
* As the XMPP bot receives events, it will write structured data directly into the Aether system's database (e.g., a `jitsi_events` table).
* This provides two key benefits: a source for the live dashboard and a clean, persistent historical record for future analysis, avoiding the fragility of parsing raw text logs.
3. **API Endpoint:**
* A new, secure endpoint (e.g., `/api/jitsi/live-stats`) will be added to the FastAPI backend.
* This endpoint will query the database to get the current state of all active meetings.
4. **Frontend Admin Dashboard:**
* A new, access-controlled page (e.g., `/admin/jitsi-stats`) will be built in the Svelte application.
* This page will periodically fetch data from the new API endpoint to display a live overview of all ongoing meetings.
### Rationale
This approach was chosen over log-parsing because the XMPP protocol is a stable, official API for Jitsi, making the solution less likely to break on future Jitsi updates. It provides true real-time data for the live dashboard while simultaneously creating a valuable, structured dataset for historical reporting.

View File

@@ -11,6 +11,12 @@
let show_jitsi_tools: boolean = $state(true);
let expand_jitsi_tools: boolean = $state(false);
// Toggles for collapsible sections
let show_meeting_details: boolean = $state(false);
let show_live_stats: boolean = $state(false);
let show_name_changer: boolean = $state(false);
let show_sound_settings: boolean = $state(false);
let user_id: null | string = $state(null);
let display_name: null | string = $state(null);
let email: null | string = $state(null);
@@ -30,6 +36,84 @@
let name_input: string = $state('');
// State for Live Meeting Stats
let meeting_participants = $state(new Map<string, any>());
let meeting_start_time: Date | null = $state(null);
let meeting_duration: string = $state('00:00:00');
let duration_timer_id: any = $state(null);
function add_jitsi_event_listeners(api: any) {
api.on('videoConferenceJoined', (data: { id: string; displayName: string }) => {
console.log('Jitsi Event: videoConferenceJoined', data);
meeting_start_time = new Date();
if (duration_timer_id) clearInterval(duration_timer_id);
duration_timer_id = setInterval(() => {
if (meeting_start_time) {
const now = new Date();
const diff = now.getTime() - meeting_start_time.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60))
.toString()
.padStart(2, '0');
const minutes = Math.floor((diff / (1000 * 60)) % 60)
.toString()
.padStart(2, '0');
const seconds = Math.floor((diff / 1000) % 60)
.toString()
.padStart(2, '0');
meeting_duration = `${hours}:${minutes}:${seconds}`;
}
}, 1000);
const local_participant = {
id: data.id,
displayName: data.displayName,
role: 'participant'
};
meeting_participants.set(data.id, local_participant);
const all_participants = api.getParticipantsInfo();
all_participants.forEach((p: { participantId: string; displayName: string }) => {
if (!meeting_participants.has(p.participantId)) {
meeting_participants.set(p.participantId, {
id: p.participantId,
displayName: p.displayName,
role: 'participant'
});
}
});
});
api.on('participantJoined', (participant: { id: string; displayName: string }) => {
console.log('Jitsi Event: participantJoined', participant);
meeting_participants.set(participant.id, {
id: participant.id,
displayName: participant.displayName,
role: 'participant'
});
});
api.on('participantLeft', (participant: { id: string }) => {
console.log('Jitsi Event: participantLeft', participant);
if (meeting_participants.has(participant.id)) {
meeting_participants.delete(participant.id);
}
});
api.on(
'participantRoleChanged',
(participant: { id: string; role: string }) => {
console.log('Jitsi Event: participantRoleChanged', participant);
if (meeting_participants.has(participant.id)) {
const p = meeting_participants.get(participant.id);
p.role = participant.role;
meeting_participants.set(participant.id, p);
}
}
);
}
async function handle_name_update() {
if (name_input && name_input.trim() !== '' && name_input !== display_name) {
console.log(`Jitsi: User updating name from "${display_name}" to "${name_input}"`);
@@ -167,6 +251,7 @@
});
onDestroy(() => {
if (duration_timer_id) clearInterval(duration_timer_id);
if (jitsi_api) {
console.log('Jitsi: Disposing of Jitsi API instance on component destroy.');
jitsi_api.dispose();
@@ -296,6 +381,12 @@
* If an existing Jitsi instance is present, it will be disposed of before creating a new one.
*/
async function init_jitsi() {
// Clear stats and timers
if (duration_timer_id) clearInterval(duration_timer_id);
meeting_participants.clear();
meeting_start_time = null;
meeting_duration = '00:00:00';
// Dispose of any existing Jitsi instance to allow for a clean restart
if (jitsi_api) {
console.log('Jitsi: Disposing of existing Jitsi API instance before re-initialization.');
@@ -365,6 +456,7 @@
console.log('Jitsi: Initializing JitsiMeetExternalAPI with options:', options);
// @ts-ignore
jitsi_api = new JitsiMeetExternalAPI(domain, options);
add_jitsi_event_listeners(jitsi_api);
console.log('Jitsi: JitsiMeetExternalAPI initialized:', jitsi_api);
}
@@ -380,127 +472,198 @@
{#if show_jitsi_tools}
<div class="jitsi-tools text-sm max-w-xl outline">
<div
class:bg-gray-400={expand_jitsi_tools}
class="jitsi-tools text-sm max-w-xl">
{#if expand_jitsi_tools}
<!-- NOTE: This is a <div> instead of <header> to work with IDAA's Novi styles. -->
<div class="flex flex-row gap-1 items-center justify-between w-full">
<!-- NOTE: This is a <div> instead of <h1> to work with IDAA's Novi styles. -->
<div class="text-lg w-fit">IDAA Jitsi</div>
<div class="text-base w-fit">
IDAA's Jitsi
{#if display_name && email}
<span class="bg-amber-50 px-1 py-0.5 rounded font-bold">{display_name} ({email})</span>
{/if}
</div>
<button
class="px-2 py-1 bg-red-200 text-white rounded hover:bg-red-400"
onclick={() => (expand_jitsi_tools = false)}
title="Close Jitsi Tools"
>
<span class="fas fa-times" aria-hidden="true"></span>
<span class="sr-only">Close Jitsi Tools</span>
</button>
</div>
<ul>
{#if display_name && email}
<li class="bg-amber-50">{display_name} ({email})</li>
{/if}
<li>Room: <span class="text-sm font-mono">{room_name}</span></li>
<li>Domain: <span class="text-sm font-mono">{domain}</span></li>
<li>User ID: <span class="text-sm font-mono">{user_id}</span></li>
<li>Moderator: {is_moderator ? 'Yes' : 'No'}</li>
</ul>
<div class="mt-2 pt-2 border-t-2 border-dashed border-gray-400">
<label for="display_name_input" class="block font-bold">Change Name:</label>
<span class="w-full flex flex-row items-center justify-between gap-1">
<input
type="text"
id="display_name_input"
bind:value={name_input}
class="border rounded px-2 py-1 w-full"
placeholder="Enter new display name"
/>
<!-- Meeting Details -->
<div class="mt-1">
<button
onclick={handle_name_update}
class="px-2 py-1 bg-green-200 text-white rounded hover:bg-green-400"
onclick={() => (show_meeting_details = !show_meeting_details)}
class="w-full flex justify-between items-center font-bold text-left"
>
<span class="fas fa-user-edit" aria-hidden="true"></span>
Update
<span class="sr-only"> Display Name</span>
Meeting Details
<span class="fas {show_meeting_details ? 'fa-chevron-up' : 'fa-chevron-down'}"></span>
</button>
</span>
{#if show_meeting_details}
<ul class="mt-1 pl-2">
<!-- {#if display_name && email}
<li class="bg-amber-50">{display_name} ({email})</li>
{/if} -->
<li>Room: <span class="text-sm font-mono font-bold">{room_name}</span></li>
<li>Domain: <span class="text-sm font-mono">{domain}</span></li>
<li>User ID: <span class="text-sm font-mono">{user_id}</span></li>
<li>Moderator: {is_moderator ? 'Yes' : 'No'}</li>
</ul>
{/if}
</div>
<!-- margin-top: 1.5em; border-top: 2px dashed #ccc; padding: 1em; background-color: pink; -->
<div
class="
mt-2 pt-2 border-t-2 border-dashed border-gray-400
flex flex-col lg:flex-row gap-05
"
<!-- Live Meeting Stats -->
<div class="mt-2 pt-2 border-t-2 border-dashed border-gray-400">
<button
onclick={() => (show_live_stats = !show_live_stats)}
class="w-full flex justify-between items-center font-bold text-left"
>
<strong>Jitsi Sound Settings:</strong>
Live Meeting Stats
<span class="fas {show_live_stats ? 'fa-chevron-up' : 'fa-chevron-down'}"></span>
</button>
{#if show_live_stats}
<div class="mt-1 pl-2">
<p>Duration: <span class="font-mono">{meeting_duration}</span></p>
<p>Total Participants: {meeting_participants.size}</p>
<div class="mt-1">
<div class="font-bold">Moderators:</div>
<ul class="pl-4 list-disc">
{#each Array.from(meeting_participants.values()).filter((p) => p.role === 'moderator') as mod (mod.id)}
<li>{mod.displayName}</li>
{:else}
<li class="text-gray-500 italic">None</li>
{/each}
</ul>
</div>
<div class="mt-1">
<h4 class="font-semibold">Participants:</h4>
<ul class="pl-4 list-disc">
{#each Array.from(meeting_participants.values()).filter((p) => p.role !== 'moderator') as person (person.id)}
<li>{person.displayName}</li>
{:else}
<li class="text-gray-500 italic">None</li>
{/each}
</ul>
</div>
</div>
{/if}
</div>
<!-- WARNING: Fully disables the Play sound on option in the Jitsi > Settings > Notifications tab. -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_incoming_msg_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Incoming Message Sound</span>
</label>
<!-- Change Name -->
<div class="mt-2 pt-2 border-t-2 border-dashed border-gray-400">
<button
onclick={() => (show_name_changer = !show_name_changer)}
class="w-full flex justify-between items-center font-bold text-left"
>
Change Name
<span class="fas {show_name_changer ? 'fa-chevron-up' : 'fa-chevron-down'}"></span>
</button>
{#if show_name_changer}
<div class="mt-1 pl-2">
<span class="w-full flex flex-row items-center justify-between gap-1">
<input
type="text"
id="display_name_input"
bind:value={name_input}
class="border rounded px-2 py-1 w-full"
placeholder="Enter new display name"
/>
<button
onclick={handle_name_update}
class="px-2 py-1 bg-green-200 text-white rounded hover:bg-green-400"
title="Update your display name"
>
<span class="fas fa-user-edit" aria-hidden="true"></span>
<span class="sr-only">Update Display Name</span>
</button>
</span>
</div>
{/if}
</div>
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_participant_joined_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Participant Joined Sound</span>
</label>
<!-- Jitsi Sound Settings -->
<div class="mt-2 pt-2 border-t-2 border-dashed border-gray-400">
<button
onclick={() => (show_sound_settings = !show_sound_settings)}
class="w-full flex justify-between items-center font-bold text-left"
>
Sound Settings
<span class="fas {show_sound_settings ? 'fa-chevron-up' : 'fa-chevron-down'}"></span>
</button>
{#if show_sound_settings}
<div class="mt-1 pl-2 flex flex-col">
<!-- WARNING: Fully disables the Play sound on option in the Jitsi > Settings > Notifications tab. -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_incoming_msg_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Incoming Message Sound</span>
</label>
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_participant_left_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Participant Left Sound</span>
</label>
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_participant_joined_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Participant Joined Sound</span>
</label>
<!-- FUTURE: Under Notifications: Talk while muted -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_participant_left_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Participant Left Sound</span>
</label>
<!-- FUTURE: Under Notifications: Participant entered lobby -->
<!-- FUTURE: Under Notifications: Talk while muted -->
<!-- FUTURE: Under Notifications: Participant entered lobby -->
<!--
FUTURE: Under Moderator:
"Everyone starts muted"
"Everyone starts hidden"
"Everyone follows me"
"Recorder follows me"
-->
<!--
FUTURE: Under Moderator:
"Everyone starts muted"
"Everyone starts hidden"
"Everyone follows me"
"Recorder follows me"
-->
<!-- WARNING: This does not seem to work. It should be unchecking Moderator options under Jitsi > Settings > Moderator tab. -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_reaction_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<!-- Full text from Jitsi Settings popup: "Mute reaction sounds for everyone" -->
<span>Disable Reaction Sound</span>
</label>
<!-- WARNING: This does not seem to work. It should be unchecking Moderator options under Jitsi > Settings > Moderator tab. -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_reaction_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<!-- Full text from Jitsi Settings popup: "Mute reaction sounds for everyone" -->
<span>Disable Reaction Sound</span>
</label>
<!-- WARNING: What does this correspond to in the Jitsi settings? -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_raise_hand_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Raise Hand Sound</span>
</label>
<!-- WARNING: What does this correspond to in the Jitsi settings? -->
<!-- NOTE: This does not seem to work as expected. It does not seem to do anything at all? -->
<label class="flex items-center space-x-2 mt-1">
<input
type="checkbox"
bind:checked={disable_raise_hand_sound}
disabled={!is_moderator}
onchange={init_jitsi}
/>
<span>Disable Raise Hand Sound</span>
</label>
</div>
{/if}
</div>
<div class="mt-2 pt-2 border-t-2 border-dashed border-gray-400">
@@ -508,6 +671,7 @@
<button
class="mt-2 px-2 py-1 bg-orange-200 text-white rounded hover:bg-orange-400"
onclick={() => init_jitsi()}
title="Re-initialize Jitsi Meeting"
>
<span class="fas fa-redo" aria-hidden="true"></span>
Re-initialize Jitsi
@@ -517,6 +681,7 @@
<button
onclick={() => jitsi_api && jitsi_api.executeCommand('endConference')}
class="mt-2 px-2 py-1 bg-red-200 text-white rounded hover:bg-red-400"
title="End meeting for all participants"
>
<span class="fas fa-phone-slash" aria-hidden="true"></span>
End Meeting for *All*
@@ -525,6 +690,7 @@
<button
onclick={() => (show_jitsi_container = !show_jitsi_container)}
class="mt-2 px-2 py-1 bg-gray-200 text-white rounded hover:bg-gray-400"
title="{show_jitsi_container ? 'Hide' : 'Show'} Jitsi Meeting"
>
{#if show_jitsi_container}
<span class="fas fa-video-slash" aria-hidden="true"></span>
@@ -543,6 +709,7 @@
// Placeholder for function calls to update Novi data
console.log('Re-sync Novi Data button clicked. Implement as needed.');
}}
title="Re-synchronize Novi data"
>
<span class="fas fa-sync" aria-hidden="true"></span>
Re-sync Novi Data
@@ -580,7 +747,7 @@
position: fixed;
bottom: 6em;
right: 20px;
background-color: rgba(255, 255, 255, 0.8);
/* background-color: rgba(255, 255, 255, 0.8); */
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);