forked from jmug/cactoide
feat: federation instance list and api/healthz improvements
This commit is contained in:
@@ -9,6 +9,9 @@ const config = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'localhost:5174'
|
url: 'localhost:5174'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'localhost:5175'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
{t('navigation.create')}
|
{t('navigation.create')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={() => goto('/instance')}
|
||||||
|
class={isActive('/instance') ? 'text-violet-400' : 'cursor-pointer'}
|
||||||
|
>
|
||||||
|
{t('navigation.instance')}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={() => goto('/event')}
|
on:click={() => goto('/event')}
|
||||||
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}
|
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"discover": "Scopri",
|
"discover": "Scopri",
|
||||||
"create": "Crea",
|
"create": "Crea",
|
||||||
"myEvents": "I Miei Eventi"
|
"myEvents": "I Miei Eventi",
|
||||||
|
"instance": "Istanza"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Cactoide - Il sito per gli RSVP",
|
"title": "Cactoide - Il sito per gli RSVP",
|
||||||
|
|||||||
@@ -99,7 +99,8 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"myEvents": "My Events"
|
"myEvents": "My Events",
|
||||||
|
"instance": "Instance"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Cactoide - The RSVP site",
|
"title": "Cactoide - The RSVP site",
|
||||||
|
|||||||
34
src/routes/api/federation/info/+server.ts
Normal file
34
src/routes/api/federation/info/+server.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,12 +4,24 @@ import { database } from '$lib/database/db';
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const startTime = performance.now();
|
||||||
try {
|
try {
|
||||||
await database.execute(sql`select 1`);
|
await database.execute(sql`select 1`);
|
||||||
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
const responseTime = Math.round(performance.now() - startTime);
|
||||||
} catch (err) {
|
|
||||||
return json(
|
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' } }
|
{ status: 503, headers: { 'cache-control': 'no-store' } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/routes/instance/+page.server.ts
Normal file
119
src/routes/instance/+page.server.ts
Normal 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: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
136
src/routes/instance/+page.svelte
Normal file
136
src/routes/instance/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user