diff --git a/GEMINI.md b/GEMINI.md index 9ff114dc..1cb3f005 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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. \ No newline at end of file diff --git a/src/routes/idaa/(idaa)/video_conferences/+page.svelte b/src/routes/idaa/(idaa)/video_conferences/+page.svelte index c0a301d0..ac9b9846 100644 --- a/src/routes/idaa/(idaa)/video_conferences/+page.svelte +++ b/src/routes/idaa/(idaa)/video_conferences/+page.svelte @@ -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()); + 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} -
+
{#if expand_jitsi_tools}
-
IDAA Jitsi
+
+ IDAA's Jitsi + {#if display_name && email} + {display_name} ({email}) + {/if} +
-
    - {#if display_name && email} -
  • {display_name} ({email})
  • - {/if} -
  • Room: {room_name}
  • -
  • Domain: {domain}
  • -
  • User ID: {user_id}
  • -
  • Moderator: {is_moderator ? 'Yes' : 'No'}
  • -
- -
- - - + +
- + {#if show_meeting_details} +
    + +
  • Room: {room_name}
  • +
  • Domain: {domain}
  • +
  • User ID: {user_id}
  • +
  • Moderator: {is_moderator ? 'Yes' : 'No'}
  • +
+ {/if}
- -
+
+ + {#if show_live_stats} +
+

Duration: {meeting_duration}

+

Total Participants: {meeting_participants.size}

+
+
Moderators:
+
    + {#each Array.from(meeting_participants.values()).filter((p) => p.role === 'moderator') as mod (mod.id)} +
  • {mod.displayName}
  • + {:else} +
  • None
  • + {/each} +
+
+
+

Participants:

+
    + {#each Array.from(meeting_participants.values()).filter((p) => p.role !== 'moderator') as person (person.id)} +
  • {person.displayName}
  • + {:else} +
  • None
  • + {/each} +
+
+
+ {/if} +
- - + +
+ + {#if show_name_changer} +
+ + + + +
+ {/if} +
- + +
+ + {#if show_sound_settings} +
+ + - + - + - + + - + - - - + + + - - - + + + +
+ {/if}
@@ -508,6 +671,7 @@