security: move jitsi_reports inside (idaa) auth gate
jitsi_reports was previously at src/routes/idaa/jitsi_reports/ and was not protected by the (idaa) layout auth gate. Moved to src/routes/idaa/(idaa)/jitsi_reports/ — same URL, now requires trusted_access or Novi-verified authenticated access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
430
src/routes/idaa/(idaa)/jitsi_reports/+page.svelte
Normal file
430
src/routes/idaa/(idaa)/jitsi_reports/+page.svelte
Normal file
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
|
||||
interface MeetingEvent {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
details: { full_name?: string };
|
||||
}
|
||||
|
||||
interface MeetingParticipant {
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface MeetingReport {
|
||||
meeting_id: string;
|
||||
room_name: string;
|
||||
start_time: string;
|
||||
final_duration: string;
|
||||
final_participant_count: number;
|
||||
final_participants: MeetingParticipant[];
|
||||
events: MeetingEvent[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: { streamed: { meetings: Promise<MeetingReport[]> } };
|
||||
}
|
||||
let { data }: Props = $props();
|
||||
|
||||
// --- Data state ---
|
||||
// Resolve the streamed promise into reactive state so we can filter and export it.
|
||||
let meetings_all = $state<MeetingReport[]>([]);
|
||||
let meetings_loading = $state(true);
|
||||
let meetings_error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
meetings_loading = true;
|
||||
meetings_error = null;
|
||||
data.streamed.meetings
|
||||
.then((m: MeetingReport[]) => {
|
||||
meetings_all = m ?? [];
|
||||
meetings_loading = false;
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
meetings_error = err.message;
|
||||
meetings_loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// --- Filter state ---
|
||||
// Default 0 so historical logs (which lack an init record and have count=0) are visible.
|
||||
// Once logging is fully established, this can be raised to 1 to hide empty/test meetings.
|
||||
let filter_min_participants = $state(0);
|
||||
let filter_room_name = $state('');
|
||||
let filter_date_from = $state('');
|
||||
let filter_date_to = $state('');
|
||||
|
||||
let filters_are_modified = $derived(
|
||||
filter_min_participants !== 0 ||
|
||||
filter_room_name !== '' ||
|
||||
filter_date_from !== '' ||
|
||||
filter_date_to !== ''
|
||||
);
|
||||
|
||||
function reset_filters() {
|
||||
filter_min_participants = 0;
|
||||
filter_room_name = '';
|
||||
filter_date_from = '';
|
||||
filter_date_to = '';
|
||||
}
|
||||
|
||||
// --- Derived: filtered meetings ---
|
||||
let meetings_filtered = $derived.by(() => {
|
||||
return meetings_all.filter((m) => {
|
||||
if ((m.final_participant_count ?? 0) < filter_min_participants) return false;
|
||||
if (filter_room_name && !m.room_name?.toLowerCase().includes(filter_room_name.toLowerCase())) return false;
|
||||
if (filter_date_from) {
|
||||
if (Date.parse(m.start_time) < Date.parse(filter_date_from)) return false;
|
||||
}
|
||||
if (filter_date_to) {
|
||||
// Include full end-of-day by appending T23:59:59 to the date string
|
||||
if (Date.parse(m.start_time) > Date.parse(filter_date_to + 'T23:59:59.999')) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// --- Summary stats ---
|
||||
function parse_duration_seconds(d: string): number {
|
||||
if (!d) return 0;
|
||||
const parts = d.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
}
|
||||
|
||||
function format_seconds(total: number): string {
|
||||
const h = Math.floor(total / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((total % 3600) / 60).toString().padStart(2, '0');
|
||||
const s = Math.floor(total % 60).toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
let summary = $derived.by(() => {
|
||||
const count = meetings_filtered.length;
|
||||
const total_participants = meetings_filtered.reduce((sum, m) => sum + (m.final_participant_count ?? 0), 0);
|
||||
const total_secs = meetings_filtered.reduce((sum, m) => sum + parse_duration_seconds(m.final_duration), 0);
|
||||
const avg_secs = count > 0 ? Math.round(total_secs / count) : 0;
|
||||
return {
|
||||
count,
|
||||
total_participants,
|
||||
avg_duration: format_seconds(avg_secs),
|
||||
total_duration: format_seconds(total_secs)
|
||||
};
|
||||
});
|
||||
|
||||
// --- Accordion state ---
|
||||
let open_accordions = $state<{ [key: string]: boolean }>({});
|
||||
|
||||
function toggle_accordion(meeting_id: string) {
|
||||
open_accordions[meeting_id] = !open_accordions[meeting_id];
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
function download_file(content: string, filename: string, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function export_csv() {
|
||||
const rows: string[][] = [
|
||||
['Meeting ID', 'Room Name', 'Start Time', 'Duration', 'Participant Count']
|
||||
];
|
||||
for (const m of meetings_filtered) {
|
||||
rows.push([
|
||||
m.meeting_id ?? '',
|
||||
m.room_name ?? '',
|
||||
m.start_time ? new Date(m.start_time).toISOString() : '',
|
||||
m.final_duration ?? '',
|
||||
String(m.final_participant_count ?? 0)
|
||||
]);
|
||||
}
|
||||
const csv = rows
|
||||
.map((r) => r.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
download_file(csv, 'jitsi_meeting_report.csv', 'text/csv;charset=utf-8;');
|
||||
}
|
||||
|
||||
function export_json() {
|
||||
download_file(
|
||||
JSON.stringify(meetings_filtered, null, 2),
|
||||
'jitsi_meeting_report.json',
|
||||
'application/json'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Æ: Jitsi Meeting Reports</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-4 space-y-4 w-full max-w-5xl">
|
||||
|
||||
<!-- Page header + export buttons -->
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<h1 class="text-xl font-bold">Jitsi Meeting Reports</h1>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={export_csv}
|
||||
disabled={meetings_filtered.length === 0}
|
||||
title="Export filtered meetings as CSV"
|
||||
class="btn btn-sm preset-tonal-primary disabled:opacity-40"
|
||||
>
|
||||
<span class="fas fa-file-csv" aria-hidden="true"></span>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={export_json}
|
||||
disabled={meetings_filtered.length === 0}
|
||||
title="Export filtered meetings as JSON"
|
||||
class="btn btn-sm preset-tonal-surface border border-surface-200-800 disabled:opacity-40"
|
||||
>
|
||||
<span class="fas fa-file-code" aria-hidden="true"></span>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 flex flex-row flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label for="filter_min_p" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
|
||||
Min. Participants
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="filter_min_p"
|
||||
min="0"
|
||||
bind:value={filter_min_participants}
|
||||
class="border border-surface-200-800 rounded px-2 py-1 w-20 bg-surface-50-950"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter_room" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="filter_room"
|
||||
placeholder="Search rooms..."
|
||||
bind:value={filter_room_name}
|
||||
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter_date_from" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter_date_from"
|
||||
bind:value={filter_date_from}
|
||||
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter_date_to" class="block text-xs uppercase tracking-wide opacity-40 mb-1">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter_date_to"
|
||||
bind:value={filter_date_to}
|
||||
class="border border-surface-200-800 rounded px-2 py-1 bg-surface-50-950"
|
||||
/>
|
||||
</div>
|
||||
{#if filters_are_modified}
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset_filters}
|
||||
class="btn btn-sm preset-tonal-surface border border-surface-200-800 self-end"
|
||||
title="Reset all filters to defaults"
|
||||
>
|
||||
<span class="fas fa-times" aria-hidden="true"></span>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if meetings_loading}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="space-y-2 animate-pulse" role="status" aria-live="polite" aria-label="Loading meeting reports">
|
||||
{#each [1, 2, 3, 4] as _, i (i)}
|
||||
<div class="h-14 w-full bg-surface-200-800 rounded-xl"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if meetings_error}
|
||||
<!-- Error state -->
|
||||
<div class="bg-error-100 border border-error-300 rounded-xl p-4">
|
||||
<div class="font-bold">Error Loading Reports</div>
|
||||
<p class="mt-1">An error occurred while fetching the meeting reports:</p>
|
||||
<pre class="mt-2 text-xs overflow-auto whitespace-pre-wrap">{meetings_error}</pre>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Summary stats -->
|
||||
{#if meetings_all.length > 0}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
|
||||
<div class="text-2xl font-bold">{summary.count}</div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40">Meetings Shown</div>
|
||||
</div>
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
|
||||
<div class="text-2xl font-bold">{summary.total_participants}</div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40">Total Participants</div>
|
||||
</div>
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
|
||||
<div class="text-2xl font-bold font-mono text-lg">{summary.avg_duration}</div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40">Avg Duration</div>
|
||||
</div>
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-3 text-center">
|
||||
<div class="text-2xl font-bold font-mono text-lg">{summary.total_duration}</div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40">Total Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meeting list -->
|
||||
{#if meetings_filtered.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each meetings_filtered as meeting (meeting.meeting_id)}
|
||||
<div class="bg-surface-50-900 border border-surface-200-800 rounded-xl overflow-hidden">
|
||||
|
||||
<!-- Accordion header -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="p-3 cursor-pointer hover:bg-surface-100-900 transition-colors duration-200"
|
||||
onclick={() => toggle_accordion(meeting.meeting_id)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate">{meeting.room_name}</div>
|
||||
<div class="text-sm opacity-60">{new Date(meeting.start_time).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center gap-4 text-sm opacity-60 flex-none">
|
||||
<span title="Duration">
|
||||
<span class="fas fa-clock mr-1" aria-hidden="true"></span>
|
||||
{meeting.final_duration}
|
||||
</span>
|
||||
<span title="Participant count">
|
||||
<span class="fas fa-users mr-1" aria-hidden="true"></span>
|
||||
{meeting.final_participant_count}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none pl-2">
|
||||
<span
|
||||
class="fas transition-transform duration-200 inline-block"
|
||||
class:fa-chevron-down={!open_accordions[meeting.meeting_id]}
|
||||
class:fa-chevron-up={open_accordions[meeting.meeting_id]}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile stats row -->
|
||||
<div class="flex gap-4 text-sm opacity-60 mt-1 sm:hidden">
|
||||
<span><span class="fas fa-clock mr-1" aria-hidden="true"></span>{meeting.final_duration}</span>
|
||||
<span>
|
||||
<span class="fas fa-users mr-1" aria-hidden="true"></span>
|
||||
{meeting.final_participant_count}
|
||||
{meeting.final_participant_count === 1 ? 'participant' : 'participants'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accordion body -->
|
||||
{#if open_accordions[meeting.meeting_id]}
|
||||
<div class="border-t border-surface-200-800 p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Event Timeline -->
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">Event Timeline</div>
|
||||
{#if meeting.events && meeting.events.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each meeting.events as event, i (i)}
|
||||
<li class="flex gap-2 items-start text-sm">
|
||||
<span class="font-mono text-xs opacity-60 whitespace-nowrap mt-0.5">
|
||||
[{new Date(event.timestamp).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-semibold">
|
||||
{ae_util.to_title_case(event.action.replace('jitsi_meeting_', ''))}
|
||||
</span>
|
||||
{#if event.details?.full_name}
|
||||
<span class="opacity-60"> — {event.details.full_name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm opacity-60 italic">No discrete events recorded.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Final Participants -->
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">
|
||||
Final Participants ({meeting.final_participant_count})
|
||||
</div>
|
||||
{#if meeting.final_participants && meeting.final_participants.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-surface-200-800">
|
||||
<th class="text-left py-1 font-medium opacity-60">Name</th>
|
||||
<th class="text-left py-1 font-medium opacity-60">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each meeting.final_participants as participant (participant.displayName)}
|
||||
<tr class="border-b border-surface-200-800 transition-colors duration-200 hover:bg-surface-100-900">
|
||||
<td class="py-1">{participant.displayName}</td>
|
||||
<td class="py-1">{ae_util.to_title_case(participant.role)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm opacity-60 italic">No participant data available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Empty state -->
|
||||
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-6 text-center">
|
||||
{#if meetings_all.length > 0}
|
||||
<div class="font-semibold">No meetings match the current filters</div>
|
||||
<p class="text-sm opacity-60 mt-1">Try lowering the minimum participants or clearing the date range.</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset_filters}
|
||||
class="btn btn-sm preset-tonal-surface border border-surface-200-800 mt-3"
|
||||
>
|
||||
<span class="fas fa-times mr-1" aria-hidden="true"></span>
|
||||
Reset Filters
|
||||
</button>
|
||||
{:else}
|
||||
<div class="font-semibold">No Meeting Reports Found</div>
|
||||
<p class="text-sm opacity-60 mt-1">There are no Jitsi activity logs to display.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
32
src/routes/idaa/(idaa)/jitsi_reports/+page.ts
Normal file
32
src/routes/idaa/(idaa)/jitsi_reports/+page.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { load_jitsi_report } from '$lib/ae_reports/reports_functions';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
console.log('*** /idaa/jitsi_reports/+page.ts ***');
|
||||
|
||||
const api_cfg = get(ae_api);
|
||||
const account_id = get(ae_loc)?.account_id;
|
||||
|
||||
if (!api_cfg || !account_id) {
|
||||
console.error('API config or Account ID not available for loading Jitsi reports.');
|
||||
return {
|
||||
streamed: {
|
||||
meetings: Promise.resolve([])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const meetings_promise = load_jitsi_report({
|
||||
api_cfg,
|
||||
account_id,
|
||||
log_lvl: 1
|
||||
});
|
||||
|
||||
return {
|
||||
streamed: {
|
||||
meetings: meetings_promise
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user