fix(imports): point to element_data_store_v3 and restore Data Store v3; commit workspace updates
This commit is contained in:
@@ -38,7 +38,6 @@ export async function qry__jitsi_report({
|
|||||||
// Step 1: Query all relevant activity logs from the API.
|
// Step 1: Query all relevant activity logs from the API.
|
||||||
const search_query = {
|
const search_query = {
|
||||||
or: [
|
or: [
|
||||||
{ field: 'name', op: 'eq', value: 'jitsi_meeting_stats_update' },
|
|
||||||
{ field: 'name', op: 'eq', value: 'jitsi_meeting_event' },
|
{ field: 'name', op: 'eq', value: 'jitsi_meeting_event' },
|
||||||
{ field: 'name', op: 'eq', value: 'jitsi_meeting_stats' }
|
{ field: 'name', op: 'eq', value: 'jitsi_meeting_stats' }
|
||||||
],
|
],
|
||||||
|
|||||||
430
src/lib/elements/element_data_store_v3.svelte
Normal file
430
src/lib/elements/element_data_store_v3.svelte
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { Modal } from 'flowbite-svelte';
|
||||||
|
import { liveQuery } from 'dexie';
|
||||||
|
|
||||||
|
import { api } from '$lib/api/api';
|
||||||
|
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
|
||||||
|
import { db_core } from '$lib/ae_core/db_core';
|
||||||
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
|
import type { ae_DataStore } from '$lib/types/ae_types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
log_lvl?: number;
|
||||||
|
expire_minutes?: number;
|
||||||
|
mount_reload_sec?: number;
|
||||||
|
ds_code: string;
|
||||||
|
ds_name?: null | string;
|
||||||
|
ds_type?: string;
|
||||||
|
for_type?: null | string;
|
||||||
|
for_id?: null | string;
|
||||||
|
class_li?: string;
|
||||||
|
display?: string;
|
||||||
|
try_cache?: boolean;
|
||||||
|
hide?: boolean;
|
||||||
|
show_edit?: boolean;
|
||||||
|
show_edit_btn?: boolean;
|
||||||
|
show_view?: boolean;
|
||||||
|
ds_loaded?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
ds_loading_status?: string;
|
||||||
|
val_sql?: null | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
log_lvl = 0,
|
||||||
|
expire_minutes = 15,
|
||||||
|
mount_reload_sec = 0,
|
||||||
|
ds_code,
|
||||||
|
ds_name = null,
|
||||||
|
ds_type = 'text',
|
||||||
|
for_type = null,
|
||||||
|
for_id = null,
|
||||||
|
class_li = '',
|
||||||
|
display = undefined as string | undefined,
|
||||||
|
try_cache = true,
|
||||||
|
hide = false,
|
||||||
|
show_edit = $bindable(false),
|
||||||
|
show_edit_btn = false,
|
||||||
|
show_view = $bindable(true),
|
||||||
|
ds_loaded = $bindable(false),
|
||||||
|
debug = false,
|
||||||
|
ds_loading_status = $bindable('starting'),
|
||||||
|
val_sql = $bindable(null)
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Local reactive state
|
||||||
|
let trigger: null | string = $state(null);
|
||||||
|
let ds_submit_results: Promise<any> | key_val | undefined = $state();
|
||||||
|
|
||||||
|
// Dexie LiveQuery for data store
|
||||||
|
// This derived observable will automatically update when dependencies change
|
||||||
|
let lq__ds_obj = $derived(
|
||||||
|
liveQuery(async () => {
|
||||||
|
const current_code = ds_code;
|
||||||
|
const account_id = $ae_loc.account_id;
|
||||||
|
const current_for_type = for_type;
|
||||||
|
const current_for_id = for_id;
|
||||||
|
|
||||||
|
if (!current_code) return null;
|
||||||
|
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${current_code}]: LQ Lookup...`, { account_id, current_for_type, current_for_id });
|
||||||
|
|
||||||
|
// Hierarchical Local Lookup (Specific -> Account -> Global)
|
||||||
|
// Mimics backend SQL priority: WHERE code = :code ORDER BY for_id DESC, account_id DESC
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${current_code}]: Fetching all matching codes for priority sorting...`);
|
||||||
|
|
||||||
|
const results = await db_core.data_store
|
||||||
|
.where('code')
|
||||||
|
.equals(current_code)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (!results || results.length === 0) return null;
|
||||||
|
|
||||||
|
// Sort by specificity
|
||||||
|
results.sort((a, b) => {
|
||||||
|
// 1. Priority: Specific Context match (for_type + for_id)
|
||||||
|
const a_context = (current_for_id && a.for_id === current_for_id && a.for_type === current_for_type) ? 1 : 0;
|
||||||
|
const b_context = (current_for_id && b.for_id === current_for_id && b.for_type === current_for_type) ? 1 : 0;
|
||||||
|
if (a_context !== b_context) return b_context - a_context;
|
||||||
|
|
||||||
|
// 2. Priority: Account-specific match
|
||||||
|
const a_account = (account_id && a.account_id === account_id) ? 1 : 0;
|
||||||
|
const b_account = (account_id && b.account_id === account_id) ? 1 : 0;
|
||||||
|
if (a_account !== b_account) return b_account - a_account;
|
||||||
|
|
||||||
|
// 3. Tie-breaker: Newest updated
|
||||||
|
const a_time = new Date(a.updated_on || a.created_on || 0).getTime();
|
||||||
|
const b_time = new Date(b.updated_on || b.created_on || 0).getTime();
|
||||||
|
return b_time - a_time;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${current_code}]: Best match found (ID: ${results[0].id}, Account: ${results[0].account_id})`);
|
||||||
|
return results[0];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync status and bound props when the live data changes
|
||||||
|
$effect(() => {
|
||||||
|
const entry = $lq__ds_obj as ae_DataStore | null;
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
ds_loaded = !!entry;
|
||||||
|
if (entry) {
|
||||||
|
ds_loading_status = 'loaded';
|
||||||
|
// Handle val_sql binding if type is sql
|
||||||
|
if (ds_type === 'sql') {
|
||||||
|
val_sql = entry.text || entry.html || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial Trigger & Context Change Guard
|
||||||
|
$effect(() => {
|
||||||
|
const account_id = $slct.account_id;
|
||||||
|
const api_ready = !!$ae_api?.base_url;
|
||||||
|
const entry = $lq__ds_obj;
|
||||||
|
|
||||||
|
if (browser && api_ready && !entry && ds_loading_status === 'starting') {
|
||||||
|
trigger = 'load__ds__code';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch handler
|
||||||
|
$effect(() => {
|
||||||
|
if (trigger === 'load__ds__code') {
|
||||||
|
untrack(() => {
|
||||||
|
trigger = null;
|
||||||
|
load_data_store();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount reload logic
|
||||||
|
onMount(() => {
|
||||||
|
if (mount_reload_sec > 0) {
|
||||||
|
const random_ms = Math.floor(Math.random() * mount_reload_sec * 1000);
|
||||||
|
setTimeout(() => { trigger = 'load__ds__code'; }, random_ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load_data_store() {
|
||||||
|
if (ds_loading_status === 'loading') return;
|
||||||
|
ds_loading_status = 'loading';
|
||||||
|
const api_cfg = untrack(() => $ae_api);
|
||||||
|
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Fetching...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt 1: Context-specific fetch
|
||||||
|
let ds_results = await api.get_data_store_v3({
|
||||||
|
api_cfg,
|
||||||
|
code: ds_code,
|
||||||
|
for_type: for_type,
|
||||||
|
for_id: for_id,
|
||||||
|
log_lvl: log_lvl
|
||||||
|
});
|
||||||
|
|
||||||
|
// V3 API structured check
|
||||||
|
const is_error = ds_results?.meta?.success === false;
|
||||||
|
const status_code = ds_results?.meta?.status_code || (ds_results === false ? 500 : 200);
|
||||||
|
|
||||||
|
// Fallback to Global if not found (404), unauthorized (403/401), or explicitly failed
|
||||||
|
if (!ds_results || is_error || status_code === 404 || status_code === 403 || status_code === 401) {
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Not found in context (Status ${status_code}). Trying global fallback.`);
|
||||||
|
|
||||||
|
ds_results = await api.get_data_store_v3({
|
||||||
|
api_cfg,
|
||||||
|
code: ds_code,
|
||||||
|
no_account_id: true,
|
||||||
|
log_lvl: log_lvl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ds_id = ds_results?.data_store_id || ds_results?.id;
|
||||||
|
|
||||||
|
if (ds_results && ds_id) {
|
||||||
|
// Map fields correctly for V3 alignment
|
||||||
|
const text_val = ds_results.text || '';
|
||||||
|
const json_val = ds_results.json || (ds_results.json_str ? JSON.parse(ds_results.json_str) : null);
|
||||||
|
|
||||||
|
// Save to Dexie
|
||||||
|
const ds_to_save: ae_DataStore = {
|
||||||
|
...ds_results,
|
||||||
|
id: ds_id,
|
||||||
|
data_store_id: ds_results.data_store_id || ds_id,
|
||||||
|
// data_store_id: ds_id,
|
||||||
|
account_id: ds_results.account_id || ds_results.account_id,
|
||||||
|
// account_id: ds_results.account_id || ds_results.account_id,
|
||||||
|
updated_on: ds_results.updated_on || new Date().toISOString(),
|
||||||
|
text: text_val,
|
||||||
|
html: text_val, // Default map text to html
|
||||||
|
json: json_val
|
||||||
|
};
|
||||||
|
|
||||||
|
await db_core.data_store.put(ds_to_save);
|
||||||
|
if (log_lvl) console.log(`ae_e_data_store [${ds_code}]: Saved to Dexie. ID: ${ds_id}`);
|
||||||
|
} else {
|
||||||
|
ds_loading_status = 'not found';
|
||||||
|
if (log_lvl) console.warn(`ae_e_data_store [${ds_code}]: Result had no valid ID.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ae_e_data_store [${ds_code}]: Fetch failed.`, err);
|
||||||
|
ds_loading_status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle_submit_form(event: Event) {
|
||||||
|
const target = event.target as HTMLFormElement;
|
||||||
|
$ae_sess.ds.submit_status = 'processing';
|
||||||
|
|
||||||
|
const form_data = new FormData(target);
|
||||||
|
const data_store_di = ae_util.extract_prefixed_form_data({
|
||||||
|
prefix: null,
|
||||||
|
form_data,
|
||||||
|
trim_values: true,
|
||||||
|
bool_tf_str: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const data_store_do: key_val = {
|
||||||
|
code: data_store_di.ds_code ?? ds_code,
|
||||||
|
name: data_store_di.ds_name ?? ds_name,
|
||||||
|
type: data_store_di.ds_type ?? ds_type,
|
||||||
|
for_type: data_store_di.ds_for_type ?? null,
|
||||||
|
for_id: data_store_di.ds_for_id ?? null,
|
||||||
|
access_read: data_store_di.ds_access_read,
|
||||||
|
access_write: data_store_di.ds_access_write,
|
||||||
|
access_delete: data_store_di.ds_access_delete,
|
||||||
|
enable: data_store_di.ds_enable ?? true,
|
||||||
|
account_id: data_store_di.ds_use_account_id ? (data_store_di.ds_account_id ?? $slct.account_id) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const content_val = data_store_di.ds_value;
|
||||||
|
if (data_store_do.type === 'json') {
|
||||||
|
data_store_do.json = content_val;
|
||||||
|
try {
|
||||||
|
// Ensure it's valid JSON if stringified
|
||||||
|
if (typeof content_val === 'string') JSON.parse(content_val);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Invalid JSON content");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data_store_do.text = content_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api_cfg = untrack(() => $ae_api);
|
||||||
|
|
||||||
|
if ($lq__ds_obj?.id) {
|
||||||
|
ds_submit_results = api.update_ae_obj_v3({
|
||||||
|
api_cfg,
|
||||||
|
obj_type: 'data_store',
|
||||||
|
obj_id: $lq__ds_obj.id,
|
||||||
|
fields: data_store_do
|
||||||
|
}).then((res) => {
|
||||||
|
if (res) {
|
||||||
|
$ae_sess.ds.submit_status = 'updated';
|
||||||
|
trigger = 'load__ds__code';
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ds_submit_results = api.create_ae_obj_v3({
|
||||||
|
api_cfg,
|
||||||
|
obj_type: 'data_store',
|
||||||
|
fields: data_store_do
|
||||||
|
}).then((res) => {
|
||||||
|
if (res) {
|
||||||
|
$ae_sess.ds.submit_status = 'created';
|
||||||
|
trigger = 'load__ds__code';
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle_delete() {
|
||||||
|
if (!$lq__ds_obj?.id || !confirm('Are you sure you want to delete this data store?')) return;
|
||||||
|
|
||||||
|
const api_cfg = untrack(() => $ae_api);
|
||||||
|
const res = await api.delete_ae_obj_v3({
|
||||||
|
api_cfg,
|
||||||
|
obj_type: 'data_store',
|
||||||
|
obj_id: $lq__ds_obj.id,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
await db_core.data_store.delete($lq__ds_obj.id);
|
||||||
|
ds_loading_status = 'not found';
|
||||||
|
show_edit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ae__elem__data_store relative {class_li}" class:hidden={hide} style={display ? `display: ${display}` : undefined}>
|
||||||
|
|
||||||
|
{#if $lq__ds_obj}
|
||||||
|
{#if debug || $ae_loc.debug === 'debug'}
|
||||||
|
Debug is ON!
|
||||||
|
<pre class="text-[10px] bg-black/10 p-2 rounded mb-2 overflow-x-auto">
|
||||||
|
ID: {$lq__ds_obj.id}
|
||||||
|
Code: {$lq__ds_obj.code}
|
||||||
|
Name: {$lq__ds_obj.name}
|
||||||
|
Type: {$lq__ds_obj.type}
|
||||||
|
Account: {$lq__ds_obj.account_id || 'Global / NULL'}
|
||||||
|
Created: {$lq__ds_obj.created_on}
|
||||||
|
Updated: {$lq__ds_obj.updated_on}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="{$lq__ds_obj.name || 'Unnamed'} - {$lq__ds_obj.code}"
|
||||||
|
bind:open={show_edit}
|
||||||
|
autoclose={false}
|
||||||
|
size="xl"
|
||||||
|
class="w-full max-w-6xl"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); handle_submit_form(e); }}>
|
||||||
|
<input type="hidden" name="ds_id_random" value={$lq__ds_obj.id} />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="text-xs font-bold opacity-70">Code</span>
|
||||||
|
<input type="text" name="ds_code" class="input font-mono" value={$lq__ds_obj.code} readonly={!$ae_loc.manager_access} required />
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
<span class="text-xs font-bold opacity-70">Name</span>
|
||||||
|
<input type="text" name="ds_name" class="input" value={$lq__ds_obj.name} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="text-xs font-bold opacity-70">Type</span>
|
||||||
|
<select name="ds_type" class="select" value={$lq__ds_obj.type}>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="md">Markdown</option>
|
||||||
|
<option value="sql">SQL</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2 pt-6">
|
||||||
|
<input type="checkbox" name="ds_use_account_id" class="checkbox" checked={!!$lq__ds_obj.account_id} />
|
||||||
|
<span class="text-xs">Account Specific</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="text-xs font-bold opacity-70">Content</span>
|
||||||
|
<textarea
|
||||||
|
name="ds_value"
|
||||||
|
class="textarea font-mono text-sm"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Enter content here..."
|
||||||
|
>{$lq__ds_obj.type === 'json' ? (typeof $lq__ds_obj.json === 'string' ? $lq__ds_obj.json : JSON.stringify($lq__ds_obj.json, null, 2)) : ($lq__ds_obj.text || $lq__ds_obj.html || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-surface-500">
|
||||||
|
Created on: {$lq__ds_obj.created_on} | Last Updated: {$lq__ds_obj.updated_on}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center pt-4">
|
||||||
|
<button type="button" class="btn variant-filled-error" onclick={handle_delete}>
|
||||||
|
<span class="fas fa-trash mr-2"></span> Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" class="btn variant-soft" onclick={() => show_edit = false}>Cancel</button>
|
||||||
|
<button type="submit" class="btn variant-filled-primary">
|
||||||
|
<span class="fas fa-save mr-2"></span> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{#if show_view}
|
||||||
|
{#if $lq__ds_obj.type === 'html' && $lq__ds_obj.html}
|
||||||
|
{@html $lq__ds_obj.html}
|
||||||
|
{:else if $lq__ds_obj.type === 'text' && $lq__ds_obj.text}
|
||||||
|
<div class="whitespace-pre-wrap">{$lq__ds_obj.text}</div>
|
||||||
|
{:else if $lq__ds_obj.type === 'sql' && $lq__ds_obj.text}
|
||||||
|
{#if debug}<div class="font-mono text-xs opacity-50">SQL: {$lq__ds_obj.text}</div>{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $ae_loc.edit_mode && ($ae_loc.manager_access || (show_edit_btn && $ae_loc.administrator_access))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-0 right-0 btn btn-sm variant-soft-warning opacity-20 hover:opacity-100 z-10"
|
||||||
|
ondblclick={() => { show_edit = true; show_view = false; }}
|
||||||
|
title="Edit Data Store: {ds_code}"
|
||||||
|
>
|
||||||
|
<span class="fas fa-edit"></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else if ds_loading_status === 'not found'}
|
||||||
|
<!-- Only show diagnostic to administrator+ (no edit_mode needed) or trusted staff in edit mode.
|
||||||
|
Anonymous/user/public visitors must never see internal data store codes or gaps. -->
|
||||||
|
{#if $ae_loc.administrator_access || ($ae_loc.trusted_access && $ae_loc.edit_mode)}
|
||||||
|
<div class="p-2 border border-dashed border-surface-500/30 rounded text-xs opacity-50">
|
||||||
|
Data Store not found: {ds_code}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if ds_loading_status === 'loading'}
|
||||||
|
<div class="absolute bottom-0 left-0 p-1 opacity-50">
|
||||||
|
<span class="fas fa-spinner fa-spin text-xs"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// import { PUBLIC_TESTING } from '$env/static/public';
|
// import { PUBLIC_TESTING } from '$env/static/public';
|
||||||
// console.log(`AE Config - +page.svelte PUBLIC_TESTING:`, PUBLIC_TESTING);
|
// console.log(`AE Config - +page.svelte PUBLIC_TESTING:`, PUBLIC_TESTING);
|
||||||
|
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
// import { api } from '$lib/api';
|
// import { api } from '$lib/api';
|
||||||
import { ae_loc, ae_sess, ae_api, slct, slct_trigger } from '$lib/stores/ae_stores';
|
import { ae_loc, ae_sess, ae_api, slct, slct_trigger } from '$lib/stores/ae_stores';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
// import { api } from '$lib/api';
|
// import { api } from '$lib/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
// import { core_func } from '$lib/ae_core_functions';
|
// import { core_func } from '$lib/ae_core_functions';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
// *** Import Aether specific variables and functions
|
// *** Import Aether specific variables and functions
|
||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
|
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import {
|
import {
|
||||||
events_loc,
|
events_loc,
|
||||||
events_sess,
|
events_sess,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
import { api } from '$lib/api/api';
|
import { api } from '$lib/api/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__events_menu_nav from '../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../ae_comp__events_menu_nav.svelte';
|
||||||
import Comp__pres_mgmt_menu_opts from '../../ae_comp__events_menu_opts.svelte';
|
import Comp__pres_mgmt_menu_opts from '../../ae_comp__events_menu_opts.svelte';
|
||||||
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
import { api } from '$lib/api/api';
|
import { api } from '$lib/api/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
||||||
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
// import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import Element_ae_obj_field_editor_v3 from '$lib/elements/element_ae_obj_field_editor_v3.svelte';
|
import Element_ae_obj_field_editor_v3 from '$lib/elements/element_ae_obj_field_editor_v3.svelte';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
let ae_promises: key_val = $state({});
|
let ae_promises: key_val = $state({});
|
||||||
// let ae_tmp: key_val = {};
|
// let ae_tmp: key_val = {};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
let ae_promises: key_val = {};
|
let ae_promises: key_val = {};
|
||||||
let ae_tmp: key_val = {};
|
let ae_tmp: key_val = {};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
events_slct
|
events_slct
|
||||||
} from '$lib/stores/ae_events_stores';
|
} from '$lib/stores/ae_events_stores';
|
||||||
|
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
|
||||||
|
|
||||||
let show_modal = $state(false);
|
let show_modal = $state(false);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// Imports (external and then internal)
|
// Imports (external and then internal)
|
||||||
// import type { key_val } from '$lib/stores/ae_stores';
|
// import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { Modal } from 'flowbite-svelte';
|
import { Modal } from 'flowbite-svelte';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import type { key_val } from '$lib/stores/ae_stores';
|
import type { key_val } from '$lib/stores/ae_stores';
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import { api } from '$lib/api/api';
|
import { api } from '$lib/api/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ae_loc,
|
ae_loc,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
import { api } from '$lib/api/api';
|
import { api } from '$lib/api/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
||||||
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
events_slct
|
events_slct
|
||||||
} from '$lib/stores/ae_events_stores';
|
} from '$lib/stores/ae_events_stores';
|
||||||
|
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../../ae_comp__events_menu_nav.svelte';
|
||||||
|
|
||||||
let show_modal = $state(false);
|
let show_modal = $state(false);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
// Import components and elements
|
// Import components and elements
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import { Check, CheckCircle, LoaderCircle, TriangleAlert, X } from '@lucide/svelte';
|
import { Check, CheckCircle, LoaderCircle, TriangleAlert, X } from '@lucide/svelte';
|
||||||
// Local Variables
|
// Local Variables
|
||||||
let ae_promises: key_val = $state({});
|
let ae_promises: key_val = $state({});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
import { api } from '$lib/api/api';
|
import { api } from '$lib/api/api';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Sign_in_out from '../../../sign_in_out.svelte';
|
import Sign_in_out from '../../../sign_in_out.svelte';
|
||||||
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
import Comp__events_menu_nav from '../../../../ae_comp__events_menu_nav.svelte';
|
||||||
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
import AE_Record_Controls from '$lib/ae_elements/AE_Record_Controls.svelte';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||||
import { events_func } from '$lib/ae_events_functions';
|
import { events_func } from '$lib/ae_events_functions';
|
||||||
|
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Comp__event_obj_qry from './ae_idaa_comp__event_obj_qry.svelte';
|
import Comp__event_obj_qry from './ae_idaa_comp__event_obj_qry.svelte';
|
||||||
import Comp__event_obj_li_wrapper from './ae_idaa_comp__event_obj_li_wrapper.svelte';
|
import Comp__event_obj_li_wrapper from './ae_idaa_comp__event_obj_li_wrapper.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,9 @@
|
|||||||
meta_json: {
|
meta_json: {
|
||||||
duration: meeting_duration,
|
duration: meeting_duration,
|
||||||
participants: participants_array,
|
participants: participants_array,
|
||||||
participant_count: participants_array.length
|
participant_count: participants_array.length,
|
||||||
|
// Verified Novi UUID of the moderator who started this log
|
||||||
|
moderator_novi_uuid: $idaa_loc.novi_uuid ?? null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -194,13 +196,15 @@
|
|||||||
|
|
||||||
api.on('participantLeft', (participant: { id: string }) => {
|
api.on('participantLeft', (participant: { id: string }) => {
|
||||||
console.log('Jitsi Event: participantLeft', participant);
|
console.log('Jitsi Event: participantLeft', participant);
|
||||||
if (meeting_participants.has(participant.id)) {
|
// Capture name before removing from map — it won't be available after delete
|
||||||
|
const p_info = meeting_participants.get(participant.id);
|
||||||
|
if (p_info) {
|
||||||
meeting_participants.delete(participant.id);
|
meeting_participants.delete(participant.id);
|
||||||
update_primary_activity_log();
|
update_primary_activity_log();
|
||||||
|
|
||||||
// NOTE: We also want to log this as a discrete event
|
|
||||||
create_discrete_activity_log('jitsi_meeting_participant_left', 'participantLeft', {
|
create_discrete_activity_log('jitsi_meeting_participant_left', 'participantLeft', {
|
||||||
attendee_id: participant.id,
|
attendee_id: participant.id,
|
||||||
|
full_name: p_info.displayName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -232,7 +236,16 @@
|
|||||||
api.on('videoConferenceLeft', () => {
|
api.on('videoConferenceLeft', () => {
|
||||||
console.log('Jitsi Event: videoConferenceLeft');
|
console.log('Jitsi Event: videoConferenceLeft');
|
||||||
if (duration_timer_id) clearInterval(duration_timer_id);
|
if (duration_timer_id) clearInterval(duration_timer_id);
|
||||||
// meeting_duration = '00:00:00';
|
|
||||||
|
// Do a final update to the primary log so it captures the true end state,
|
||||||
|
// then log the meeting end as a discrete event for the timeline.
|
||||||
|
if (is_moderator && primary_activity_log_id) {
|
||||||
|
update_primary_activity_log();
|
||||||
|
create_discrete_activity_log('jitsi_meeting_end', 'videoConferenceLeft', {
|
||||||
|
final_duration: meeting_duration,
|
||||||
|
final_participant_count: meeting_participants.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
api.on('readyToClose', () => {
|
api.on('readyToClose', () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
} from '$lib/stores/ae_stores';
|
} from '$lib/stores/ae_stores';
|
||||||
import { core_func } from '$lib/ae_core/ae_core_functions';
|
import { core_func } from '$lib/ae_core/ae_core_functions';
|
||||||
import { idaa_loc, idaa_sess, idaa_slct } from '$lib/stores/ae_idaa_stores';
|
import { idaa_loc, idaa_sess, idaa_slct } from '$lib/stores/ae_idaa_stores';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** @type {import('./$types').LayoutData} */
|
/** @type {import('./$types').LayoutData} */
|
||||||
|
|||||||
@@ -1,120 +1,428 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
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 {
|
interface Props {
|
||||||
data: any;
|
data: { streamed: { meetings: Promise<MeetingReport[]> } };
|
||||||
}
|
}
|
||||||
let { data }: Props = $props();
|
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 ---
|
||||||
|
let filter_min_participants = $state(1);
|
||||||
|
let filter_room_name = $state('');
|
||||||
|
let filter_date_from = $state('');
|
||||||
|
let filter_date_to = $state('');
|
||||||
|
|
||||||
|
let filters_are_modified = $derived(
|
||||||
|
filter_min_participants !== 1 ||
|
||||||
|
filter_room_name !== '' ||
|
||||||
|
filter_date_from !== '' ||
|
||||||
|
filter_date_to !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset_filters() {
|
||||||
|
filter_min_participants = 1;
|
||||||
|
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 }>({});
|
let open_accordions = $state<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
function toggle_accordion(meeting_id: string) {
|
function toggle_accordion(meeting_id: string) {
|
||||||
open_accordions[meeting_id] = !open_accordions[meeting_id];
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Æ: Jitsi Meeting Reports</title>
|
<title>Æ: Jitsi Meeting Reports</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4 w-full max-w-5xl">
|
||||||
<h1 class="h1">Jitsi Meeting Reports</h1>
|
|
||||||
|
|
||||||
{#await data.streamed.meetings}
|
<!-- Page header + export buttons -->
|
||||||
<div class="space-y-4 animate-pulse">
|
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||||
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
<h1 class="text-xl font-bold">Jitsi Meeting Reports</h1>
|
||||||
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
<div class="flex gap-2">
|
||||||
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
<button
|
||||||
<div class="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
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>
|
||||||
{:then meetings}
|
</div>
|
||||||
{#if meetings && meetings.length > 0}
|
|
||||||
|
<!-- 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">
|
<div class="space-y-2">
|
||||||
{#each meetings as meeting (meeting.meeting_id)}
|
{#each meetings_filtered as meeting (meeting.meeting_id)}
|
||||||
<div class="card card-hover">
|
<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_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<header class="card-header p-2 cursor-pointer" onclick={() => toggle_accordion(meeting.meeting_id)}>
|
<div
|
||||||
<div class="flex justify-between items-center w-full">
|
class="p-3 cursor-pointer hover:bg-surface-100-900 transition-colors duration-200"
|
||||||
<div class="flex-1">
|
onclick={() => toggle_accordion(meeting.meeting_id)}
|
||||||
<!-- NOTE: Normally I would the "h3" class, but Novi classes make things look odd. -->
|
>
|
||||||
<h3 class="text-base">{meeting.room_name}</h3>
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-sm text-gray-500">{new Date(meeting.start_time).toLocaleString()}</p>
|
<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>
|
||||||
<div class="flex-none flex items-center space-x-4 text-sm mr-4">
|
<div class="hidden sm:flex items-center gap-4 text-sm opacity-60 flex-none">
|
||||||
<span>Duration: {meeting.final_duration}</span>
|
<span title="Duration">
|
||||||
<span>Participants: {meeting.final_participant_count}</span>
|
<span class="fas fa-clock mr-1" aria-hidden="true"></span>
|
||||||
</div>
|
{meeting.final_duration}
|
||||||
<div class="flex-none">
|
</span>
|
||||||
<span class="transition-transform duration-200" class:rotate-180={open_accordions[meeting.meeting_id]}>
|
<span title="Participant count">
|
||||||
<svg xmlns="https://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
<span class="fas fa-users mr-1" aria-hidden="true"></span>
|
||||||
|
{meeting.final_participant_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
<!-- 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]}
|
{#if open_accordions[meeting.meeting_id]}
|
||||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="border-t border-surface-200-800 p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- Event Timeline -->
|
||||||
<div>
|
<div>
|
||||||
<!-- NOTE: Normally I would the "h4" class, but Novi classes make things look odd. -->
|
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">Event Timeline</div>
|
||||||
<h4 class="text-base">Event Timeline</h4>
|
|
||||||
{#if meeting.events && meeting.events.length > 0}
|
{#if meeting.events && meeting.events.length > 0}
|
||||||
<ul class="list-disc list-inside space-y-2 mt-2">
|
<ul class="space-y-1">
|
||||||
{#each meeting.events as event, i (i)}
|
{#each meeting.events as event, i (i)}
|
||||||
<li>
|
<li class="flex gap-2 items-start text-sm">
|
||||||
<span class="font-mono text-xs">[{new Date(event.timestamp).toLocaleTimeString()}]</span>
|
<span class="font-mono text-xs opacity-60 whitespace-nowrap mt-0.5">
|
||||||
<span class="font-semibold">{ae_util.to_title_case(event.action.replace('jitsi_meeting_', ''))}</span>
|
[{new Date(event.timestamp).toLocaleTimeString()}]
|
||||||
{#if event.details.full_name}
|
</span>
|
||||||
- by {event.details.full_name}
|
<span>
|
||||||
{/if}
|
<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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-gray-500 italic mt-2">No discrete events recorded.</p>
|
<p class="text-sm opacity-60 italic">No discrete events recorded.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Participants -->
|
||||||
<div>
|
<div>
|
||||||
<!-- NOTE: Normally I would the "h4" class, but Novi classes make things look odd. -->
|
<div class="text-xs uppercase tracking-wide opacity-40 mb-2">
|
||||||
<h4 class="text-base">Final Participants</h4>
|
Final Participants ({meeting.final_participant_count})
|
||||||
|
</div>
|
||||||
{#if meeting.final_participants && meeting.final_participants.length > 0}
|
{#if meeting.final_participants && meeting.final_participants.length > 0}
|
||||||
<div class="table-container mt-2">
|
<table class="w-full text-sm">
|
||||||
<table class="table table-hover">
|
<thead>
|
||||||
<thead>
|
<tr class="border-b border-surface-200-800">
|
||||||
<tr>
|
<th class="text-left py-1 font-medium opacity-60">Name</th>
|
||||||
<th>Name</th>
|
<th class="text-left py-1 font-medium opacity-60">Role</th>
|
||||||
<th>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>
|
</tr>
|
||||||
</thead>
|
{/each}
|
||||||
<tbody>
|
</tbody>
|
||||||
{#each meeting.final_participants as participant (participant.displayName)}
|
</table>
|
||||||
<tr>
|
|
||||||
<td>{participant.displayName}</td>
|
|
||||||
<td>{ae_util.to_title_case(participant.role)}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-gray-500 italic mt-2">No participant data available.</p>
|
<p class="text-sm opacity-60 italic">No participant data available.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card p-4 text-center">
|
<!-- Empty state -->
|
||||||
<h3 class="h3">No Meeting Reports Found</h3>
|
<div class="bg-surface-100-900 border border-surface-200-800 rounded-xl p-6 text-center">
|
||||||
<p>There are no Jitsi activity logs to display.</p>
|
{#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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:catch error}
|
|
||||||
<div class="card p-4 bg-red-100 text-red-900 border-l-4 border-red-500">
|
{/if}
|
||||||
<h3 class="h3 font-bold">Error Loading Reports</h3>
|
|
||||||
<p>An error occurred while fetching the meeting reports:</p>
|
|
||||||
<pre class="mt-2 text-xs overflow-auto">{error.message}</pre>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
journals_slct,
|
journals_slct,
|
||||||
journals_trig
|
journals_trig
|
||||||
} from '$lib/ae_journals/ae_journals_stores';
|
} from '$lib/ae_journals/ae_journals_stores';
|
||||||
import Element_data_store from '$lib/elements/element_data_store.svelte';
|
import Element_data_store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import Help_tech from '$lib/app_components/e_app_help_tech.svelte';
|
import Help_tech from '$lib/app_components/e_app_help_tech.svelte';
|
||||||
|
|
||||||
// *** Setup Svelte properties
|
// *** Setup Svelte properties
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AE_Element_Data_Store from '$lib/elements/element_data_store.svelte';
|
import AE_Element_Data_Store from '$lib/elements/element_data_store_v3.svelte';
|
||||||
import { ae_loc } from '$lib/stores/ae_stores';
|
import { ae_loc } from '$lib/stores/ae_stores';
|
||||||
import { db_core } from '$lib/ae_core/db_core';
|
import { db_core } from '$lib/ae_core/db_core';
|
||||||
import { RefreshCw, Trash2 } from '@lucide/svelte';
|
import { RefreshCw, Trash2 } from '@lucide/svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user