feat: federation instance list and api/healthz improvements

This commit is contained in:
Levente Orban
2025-11-06 22:57:27 +01:00
parent 9f74d58db1
commit c3f420df74
8 changed files with 318 additions and 5 deletions

View File

@@ -9,6 +9,9 @@ const config = {
},
{
url: 'localhost:5174'
},
{
url: 'localhost:5175'
}
]
};

View File

@@ -48,6 +48,13 @@
{t('navigation.create')}
</button>
<button
on:click={() => goto('/instance')}
class={isActive('/instance') ? 'text-violet-400' : 'cursor-pointer'}
>
{t('navigation.instance')}
</button>
<button
on:click={() => goto('/event')}
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}

View File

@@ -94,7 +94,8 @@
"home": "Home",
"discover": "Scopri",
"create": "Crea",
"myEvents": "I Miei Eventi"
"myEvents": "I Miei Eventi",
"instance": "Istanza"
},
"home": {
"title": "Cactoide - Il sito per gli RSVP",

View File

@@ -99,7 +99,8 @@
"home": "Home",
"discover": "Discover",
"create": "Create",
"myEvents": "My Events"
"myEvents": "My Events",
"instance": "Instance"
},
"home": {
"title": "Cactoide - The RSVP site",

View File

@@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { eq, count } from 'drizzle-orm';
import { logger } from '$lib/logger';
import federationConfig from '../../../../../federation.config.js';
export const GET: RequestHandler = async () => {
try {
// Count public events
const publicEventsCount = await database
.select({ count: count() })
.from(events)
.where(eq(events.visibility, 'public'));
const countValue = publicEventsCount[0]?.count || 0;
return json({
name: federationConfig.name,
publicEventsCount: countValue
});
} catch (error) {
logger.error({ error }, 'Error fetching federation info from API');
return json(
{
error: 'Failed to fetch federation info',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
};

View File

@@ -4,12 +4,24 @@ import { database } from '$lib/database/db';
import { sql } from 'drizzle-orm';
export async function GET() {
const startTime = performance.now();
try {
await database.execute(sql`select 1`);
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
} catch (err) {
const responseTime = Math.round(performance.now() - startTime);
return json(
{ ok: false, error: (err as Error)?.message, message: 'Database unreachable.' },
{ ok: true, responseTime, responseTimeUnit: 'ms' },
{ headers: { 'cache-control': 'no-store' } }
);
} catch (err) {
const responseTime = Math.round(performance.now() - startTime);
return json(
{
ok: false,
error: (err as Error)?.message,
message: 'Database unreachable.',
responseTime,
responseTimeUnit: 'ms'
},
{ status: 503, headers: { 'cache-control': 'no-store' } }
);
}

View File

@@ -0,0 +1,119 @@
import type { PageServerLoad } from './$types';
import { logger } from '$lib/logger';
import federationConfig from '../../../federation.config.js';
interface InstanceInfo {
name: string;
publicEventsCount: number;
}
interface HealthStatus {
ok: boolean;
responseTime?: number;
responseTimeUnit?: string;
error?: string;
}
interface InstanceData {
url: string;
name: string | null;
events: number | null;
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
responseTime: number | null;
error?: string;
}
async function fetchInstanceInfo(instanceUrl: string): Promise<InstanceInfo | null> {
try {
const apiUrl = `http://${instanceUrl}/api/federation/info`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch instance info');
return null;
}
const data = (await response.json()) as InstanceInfo;
return data;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching instance info'
);
return null;
}
}
async function fetchHealthStatus(instanceUrl: string): Promise<HealthStatus | null> {
try {
const apiUrl = `http://${instanceUrl}/api/healthz`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch health status');
return { ok: false, error: `HTTP ${response.status}` };
}
const data = (await response.json()) as HealthStatus;
return data;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching health status'
);
return { ok: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export const load: PageServerLoad = async () => {
try {
const instances = federationConfig.instances || [];
// Fetch data from all instances in parallel
const instanceDataPromises = instances.map(async (instance): Promise<InstanceData> => {
const [info, health] = await Promise.all([
fetchInstanceInfo(instance.url),
fetchHealthStatus(instance.url)
]);
const responseTime = health?.responseTime ?? null;
const healthStatus: 'healthy' | 'unhealthy' | 'unknown' = health?.ok
? 'healthy'
: health === null
? 'unknown'
: 'unhealthy';
return {
url: instance.url,
name: info?.name ?? null,
events: info?.publicEventsCount ?? null,
healthStatus,
responseTime,
error: health?.error
};
});
const instanceData = await Promise.all(instanceDataPromises);
return {
instances: instanceData
};
} catch (error) {
logger.error({ error }, 'Error loading instance data');
return {
instances: []
};
}
};

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { t } from '$lib/i18n/i18n.js';
interface InstanceData {
url: string;
name: string | null;
events: number | null;
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
responseTime: number | null;
error?: string;
}
type InstancePageData = {
instances: InstanceData[];
};
export let data: InstancePageData;
function getStatusColor(responseTime: number | null): string {
if (responseTime === null) return 'bg-gray-400';
if (responseTime < 10) return 'bg-green-500';
if (responseTime <= 30) return 'bg-yellow-500';
return 'bg-red-500';
}
function formatResponseTime(responseTime: number | null): string {
if (responseTime === null) return 'N/A';
return `${responseTime} ms`;
}
</script>
<div class="container mx-auto px-4 py-16 text-white">
<div class="overflow-x-auto">
<table class="min-w-full rounded-lg border border-slate-600 bg-slate-800/50 shadow-sm">
<thead class="bg-slate-800">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
Name
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
URL
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
Events
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
Health Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
Response Time
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700">
{#each data.instances as instance}
<tr class="hover:bg-slate-700/50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-medium text-slate-300">
{instance.name || 'N/A'}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<a
href="http://{instance.url}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-slate-400 hover:text-violet-300/80"
>
{instance.url}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-slate-300">
{instance.events !== null ? instance.events : 'N/A'}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
instance.responseTime
)}"
title={instance.healthStatus}
></span>
<span class="text-sm text-slate-300 capitalize">
{instance.healthStatus}
</span>
{#if instance.error}
<span class="ml-2 text-xs text-slate-500">({instance.error})</span>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
instance.responseTime
)}"
></span>
<span class="text-sm text-slate-300">
{formatResponseTime(instance.responseTime)}
</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<p class="py-8 text-center text-slate-400">
These are the instances that are part of the github original federation list, if you want to
add your instance to the list, please open a pull request to the <a
href="https://github.com/cactoide/cactoide/blob/main/federation.config.js"
class="text-violet-300/80">federation.config.js</a
> file.
</p>
{#if data.instances.length === 0}
<div class="py-8 text-center text-slate-500">No federation instances configured.</div>
{/if}
</div>
</div>
<style>
/* Additional styles if needed */
</style>