Refine IDAA Jitsi reports UX

Add Novi UUID exclusion and known-meeting filtering, default the report date range to the last 60 days, and hide Room Name unless global edit mode is enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Idem
2026-05-06 10:39:42 -04:00
parent 409308d2be
commit 3ae9d0a884
5 changed files with 253 additions and 88 deletions

View File

@@ -51,9 +51,9 @@ let show_llm_api_token = $state(false);
// Ensure we have a valid object
if (!cfg_json) cfg_json = {};
function add_to_list(key: string) {
function add_to_list(key: string, prompt_label: string) {
if (!cfg_json[key]) cfg_json[key] = [];
const val = prompt('Enter Novi UUID:');
const val = prompt(prompt_label);
if (val) cfg_json[key].push(val);
}
@@ -61,6 +61,10 @@ function remove_from_list(key: string, index: number) {
cfg_json[key].splice(index, 1);
}
function list_count(key: string): number {
return Array.isArray(cfg_json[key]) ? cfg_json[key].length : 0;
}
// Sync Raw JSON string when entering the tab
$effect(() => {
if (active_tab === 'raw') {
@@ -365,24 +369,32 @@ $effect(() => {
<!-- UUID Lists -->
<section class="grid grid-cols-1 gap-4 md:grid-cols-2">
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500' }] as list (list.key)}
<div class="bg-surface-500/5 space-y-2 rounded-lg p-3">
<header class="flex items-center justify-between">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
{#each [{ key: 'novi_admin_li', label: 'Novi Admins', color: 'text-error-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_trusted_li', label: 'Novi Trusted', color: 'text-warning-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_jitsi_mod_li', label: 'Jitsi Moderators', color: 'text-primary-500', prompt: 'Enter Novi UUID:' }, { key: 'novi_idaa_group_guid_li', label: 'Member Group GUIDs', color: 'text-secondary-500', prompt: 'Enter Group GUID:' }, { key: 'jitsi_exclude_uuids', label: 'Jitsi Excluded UUIDs', color: 'text-error-500', prompt: 'Enter Novi UUID to exclude:' }, { key: 'jitsi_known_meetings', label: 'Known IDAA Meetings', color: 'text-primary-500', prompt: 'Enter meeting name to allow:' }] as list (list.key)}
<details class="bg-surface-500/5 rounded-lg p-3">
<summary
class="flex cursor-pointer list-none items-center justify-between gap-2 [&::-webkit-details-marker]:hidden">
<span class="min-w-0">
<span
class="text-[10px] font-black tracking-wider uppercase {list.color}"
>{list.label}</span>
<span class="ml-2 text-[10px] opacity-50"
>({list_count(list.key)})</span>
</span>
<button
class="btn btn-icon btn-icon-sm variant-soft-primary"
onclick={() => add_to_list(list.key)}>
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
add_to_list(list.key, list.prompt);
}}>
<Plus size="12" />
</button>
</header>
<div class="space-y-1">
{#each cfg_json[list.key] ?? [] as uuid, i (uuid)}
</summary>
<div class="mt-3 space-y-1">
{#each cfg_json[list.key] ?? [] as item, i (i)}
<div
class="bg-surface-500/10 flex items-center gap-1 rounded p-1 font-mono text-[10px]">
<span class="grow truncate"
>{uuid}</span>
<span class="grow truncate">{item}</span>
<button
class="text-error-500 transition-transform hover:scale-110"
onclick={() =>
@@ -392,7 +404,7 @@ $effect(() => {
</div>
{/each}
</div>
</div>
</details>
{/each}
</section>

View File

@@ -24,6 +24,43 @@ function parse_duration_seconds(d: string): number {
return (h || 0) * 3600 + (m || 0) * 60 + (s || 0);
}
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function extract_participant_uuid(source: Record<string, unknown>): string {
const nested_user = source.user as Record<string, unknown> | undefined;
const nested_context = source.context as Record<string, unknown> | undefined;
const nested_context_user = nested_context?.user as
| Record<string, unknown>
| undefined;
const candidates = [
source.novi_uuid,
source.uuid,
source.novi_customer_uid,
nested_user?.novi_uuid,
nested_user?.uuid,
nested_user?.id,
nested_context_user?.novi_uuid,
nested_context_user?.uuid,
nested_context_user?.id,
source.user_id,
source.id
];
for (const candidate of candidates) {
const uuid = normalize_uuid(candidate);
if (uuid) return uuid;
}
return '';
}
/**
* @description Queries all Jitsi-related activity logs and processes them into a structured report,
* grouped by meeting ID.
@@ -118,14 +155,16 @@ export async function qry__jitsi_report({
// 1. jitsi_meeting_participant_joined events: meta.full_name (all who ever joined)
// 2. jitsi_meeting_init / stats / end snapshots: meta.participants[].displayName+role
// Source 2 has role info; source 1 may catch participants who left before the snapshot.
// We merge both into a Map<displayName, role> per meeting, deduplicating by name.
// We merge both into a Map<displayName, participant> per meeting, keeping the
// stable display-name key used by the existing report while preserving Novi UUIDs
// when the log payload includes them.
//
// Duration: take the MAX across all init/stats/end events — periodic stats may
// have a higher value than the final init summary in some Jitsi configurations.
const meetings = new Map<string, any>();
// Per-meeting participant map: displayName → role
const participant_maps = new Map<string, Map<string, string>>();
// Per-meeting participant map: displayName → participant record
const participant_maps = new Map<string, Map<string, any>>();
// Per-meeting max observed duration in seconds
const max_duration_secs = new Map<string, number>();
@@ -177,15 +216,35 @@ export async function qry__jitsi_report({
// Merge snapshot participant list (has role info — preferred source)
const snapshot = meta.participants as
| Array<{ displayName?: string; role?: string }>
| Array<Record<string, unknown>>
| undefined;
if (Array.isArray(snapshot)) {
for (const p of snapshot) {
if (!p.displayName) continue;
// Only overwrite an existing entry if we're upgrading from participant → moderator
const existing_role = p_map.get(p.displayName);
if (!existing_role || (existing_role !== 'moderator' && p.role === 'moderator')) {
p_map.set(p.displayName, p.role ?? 'participant');
const display_name = normalize_text(p.displayName);
if (!display_name) continue;
const role =
typeof p.role === 'string' ? p.role : 'participant';
const novi_uuid = extract_participant_uuid(p);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
continue;
}
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
@@ -193,9 +252,30 @@ export async function qry__jitsi_report({
// Collect participants from join events (may catch people who left before the snapshot)
if (log.action === 'jitsi_meeting_participant_joined') {
const full_name = (meta.full_name as string) || '';
if (full_name && !p_map.has(full_name)) {
p_map.set(full_name, (meta.role as string) ?? 'participant');
const display_name = normalize_text(meta.full_name);
if (display_name) {
const role =
typeof meta.role === 'string' ? meta.role : 'participant';
const novi_uuid = extract_participant_uuid(meta);
const existing_participant = p_map.get(display_name);
if (!existing_participant) {
p_map.set(display_name, {
displayName: display_name,
role,
...(novi_uuid ? { novi_uuid } : {})
});
} else {
if (
existing_participant.role !== 'moderator' &&
role === 'moderator'
) {
existing_participant.role = 'moderator';
}
if (!existing_participant.novi_uuid && novi_uuid) {
existing_participant.novi_uuid = novi_uuid;
}
}
}
}
@@ -217,8 +297,7 @@ export async function qry__jitsi_report({
if (!meeting_report) continue;
if (p_map.size > 0) {
// Sort: moderators first, then alphabetically
meeting_report.final_participants = Array.from(p_map.entries())
.map(([displayName, role]) => ({ displayName, role }))
meeting_report.final_participants = Array.from(p_map.values())
.sort((a: any, b: any) => {
if (a.role === 'moderator' && b.role !== 'moderator') return -1;
if (a.role !== 'moderator' && b.role === 'moderator') return 1;

View File

@@ -15,6 +15,7 @@ interface MeetingEvent {
interface MeetingParticipant {
displayName: string;
role: string;
novi_uuid?: string;
}
interface MeetingReport {
@@ -39,6 +40,45 @@ let meetings_loading = $state(true);
let meetings_error = $state<string | null>(null);
let meetings_load_started = $state(false);
function normalize_text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalize_uuid(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_meeting_name(value: unknown): string {
return normalize_text(value).toLowerCase();
}
function normalize_list(value: unknown): string[] {
return Array.isArray(value)
? value
.map((item) => normalize_text(item))
.filter((item) => item.length > 0)
: [];
}
function participant_identity(p: MeetingParticipant): string {
const uuid = normalize_uuid(p.novi_uuid);
if (uuid) return `uuid:${uuid}`;
return `name:${normalize_meeting_name(p.displayName)}`;
}
function format_date_input_value(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
const today = new Date();
const default_filter_date_to = format_date_input_value(today);
const default_filter_date_from = format_date_input_value(
new Date(today.getFullYear(), today.getMonth(), today.getDate() - 60)
);
$effect(() => {
if (meetings_load_started) return;
if (!$idaa_loc.novi_verified && !$ae_loc.trusted_access) return;
@@ -70,13 +110,29 @@ $effect(() => {
});
// --- Staff/test exclusion ---
// Read display names to exclude from site_cfg_json.jitsi_exclude_names.
// Matched case-insensitively against MeetingParticipant.displayName.
let exclude_names = $derived<string[]>(
(($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names as string[] | undefined)
?.map((n) => n.toLowerCase().trim()) ?? []
// UUID-based exclusion is the canonical path; the legacy name list stays as a fallback
// for older site config values until those are cleaned up.
let exclude_uuids = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_uuids
).map((uuid) => uuid.toLowerCase())
);
let exclude_names = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_exclude_names
).map((name) => name.toLowerCase())
);
let known_meetings = $derived<string[]>(
normalize_list(
($ae_loc.site_cfg_json as Record<string, unknown>)
?.jitsi_known_meetings
).map((name) => name.toLowerCase())
);
let exclude_uuid_set = $derived(new Set(exclude_uuids));
let exclude_name_set = $derived(new Set(exclude_names));
let known_meeting_set = $derived(new Set(known_meetings));
// Apply exclusion to every meeting — produces the "real" participant list
// used by all downstream filters, stats, and display.
@@ -84,15 +140,19 @@ let meetings_enriched = $derived<MeetingReportEnriched[]>(
meetings_all.map((m) => {
// final_participants can be null/undefined if meta_json.participants was null in the API response
const all_participants: MeetingParticipant[] = m.final_participants ?? [];
const real_participants =
exclude_names.length > 0
? all_participants.filter(
(p) =>
!exclude_names.includes(
p.displayName.toLowerCase().trim()
)
)
: all_participants;
const real_participants = all_participants.filter((p) => {
const participant_uuid = normalize_uuid(p.novi_uuid);
if (participant_uuid && exclude_uuid_set.has(participant_uuid)) {
return false;
}
const participant_name = normalize_meeting_name(p.displayName);
if (exclude_name_set.has(participant_name)) {
return false;
}
return true;
});
return {
...m,
real_participants,
@@ -108,23 +168,23 @@ let group_by_room = $state(true);
let filter_real_only = $state(false);
let filter_min_participants = $state(0);
let filter_room_name = $state('');
let filter_date_from = $state('');
let filter_date_to = $state('');
let filter_date_from = $state(default_filter_date_from);
let filter_date_to = $state(default_filter_date_to);
let filters_are_modified = $derived(
filter_real_only ||
filter_min_participants !== 0 ||
filter_room_name !== '' ||
filter_date_from !== '' ||
filter_date_to !== ''
filter_date_from !== default_filter_date_from ||
filter_date_to !== default_filter_date_to
);
function reset_filters() {
filter_real_only = false;
filter_min_participants = 0;
filter_room_name = '';
filter_date_from = '';
filter_date_to = '';
filter_date_from = default_filter_date_from;
filter_date_to = default_filter_date_to;
}
// --- Duration helpers ---
@@ -154,6 +214,11 @@ function compute_end_time(start_time: string, duration: string): string {
// All counts and thresholds use real_participant_count (post-exclusion).
let meetings_filtered = $derived.by(() => {
return meetings_enriched.filter((m) => {
if (known_meeting_set.size > 0) {
if (!known_meeting_set.has(normalize_meeting_name(m.room_name))) {
return false;
}
}
if (filter_real_only) {
const dur_secs = parse_duration_seconds(m.final_duration);
if (m.real_participant_count < 2 && dur_secs <= 300) return false;
@@ -230,7 +295,7 @@ function room_stats(sessions: MeetingReportEnriched[]) {
0
);
const unique_names = new Set(
sessions.flatMap((m) => m.real_participants.map((p) => p.displayName))
sessions.flatMap((m) => m.real_participants.map((p) => participant_identity(p)))
);
return {
meeting_count: sessions.length,
@@ -424,17 +489,19 @@ function export_json() {
class="border-surface-200-800 bg-surface-50-950 w-20 rounded border px-2 py-1" />
</div>
<div>
<label
for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
{#if $ae_loc.edit_mode}
<label
for="filter_room"
class="mb-1 block text-xs tracking-wide uppercase opacity-40">
Room Name
</label>
<input
type="text"
id="filter_room"
placeholder="Search rooms..."
bind:value={filter_room_name}
class="border-surface-200-800 bg-surface-50-950 rounded border px-2 py-1" />
{/if}
</div>
<div>
<label
@@ -564,7 +631,7 @@ function export_json() {
? 'session'
: 'sessions'}
</span>
<span>
<span title="Unique participants (by Novi UUID when available)">
<span
class="fas fa-users mr-1"
aria-hidden="true"></span>