mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-21 21:55:27 +00:00
Compare commits
20 Commits
fix/insecu
...
fix/custom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ecaf54227 | ||
|
|
406a669a98 | ||
|
|
7d75020cc1 | ||
|
|
73c92b800a | ||
|
|
1faa45e76b | ||
|
|
8a45ad60fb | ||
|
|
258b822a27 | ||
|
|
c3f420df74 | ||
|
|
9f74d58db1 | ||
|
|
efe465d994 | ||
|
|
1b79e6da58 | ||
|
|
5ea620762a | ||
|
|
e5be4b5589 | ||
|
|
8cde1d44eb | ||
|
|
7692f9d503 | ||
|
|
baf3fcd923 | ||
|
|
5468bc7cb2 | ||
|
|
0afe331cab | ||
|
|
fa9a79192c | ||
|
|
7275084ab9 |
12
.env.example
12
.env.example
@@ -4,10 +4,20 @@ POSTGRES_USER=cactoide
|
||||
POSTGRES_PASSWORD=cactoide_password
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Application configuration
|
||||
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
|
||||
|
||||
# Application configuration
|
||||
APP_VERSION=latest
|
||||
PORT=5173
|
||||
HOSTNAME=0.0.0.0
|
||||
|
||||
# Logger configuration
|
||||
LOG_PRETTY=true
|
||||
LOG_LEVEL=trace
|
||||
|
||||
# If you don't want to use the default home page you can turn off
|
||||
# in this case the /discovery page remain the home of your site
|
||||
PUBLIC_LANDING_INFO=true
|
||||
|
||||
# Federation config
|
||||
FEDERATION_INSTANCE=true
|
||||
|
||||
1
.github/workflows/build-and-push.yml
vendored
1
.github/workflows/build-and-push.yml
vendored
@@ -61,6 +61,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
FEDERATION_INSTANCE=${{ vars.FEDERATION_INSTANCE }}
|
||||
PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }}
|
||||
LOG_PRETTY=${{ vars.LOG_PRETTY }}
|
||||
LOG_LEVEL=${{ vars.LOG_LEVEL }}
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -29,6 +29,7 @@ jobs:
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
env:
|
||||
FEDERATION_INSTANCE: ${{ vars.FEDERATION_INSTANCE }}
|
||||
PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }}
|
||||
LOG_PRETTY: ${{ vars.LOG_PRETTY }}
|
||||
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
|
||||
|
||||
@@ -7,6 +7,9 @@ RUN npm ci
|
||||
ARG PUBLIC_LANDING_INFO
|
||||
ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO
|
||||
|
||||
ARG FEDERATION_INSTANCE
|
||||
ENV FEDERATION_INSTANCE=$FEDERATION_INSTANCE
|
||||
|
||||
ARG LOG_PRETTY
|
||||
ENV LOG_PRETTY=$LOG_PRETTY
|
||||
|
||||
@@ -29,7 +32,7 @@ ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthz || exit 1
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "node", "build" ]
|
||||
|
||||
74
README.md
74
README.md
@@ -5,7 +5,7 @@ Events that thrive anywhere.
|
||||
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cactoide.dalev.hu/" target="blank">
|
||||
<a href="https://cactoide.org/" target="blank">
|
||||
<picture>
|
||||
<img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840">
|
||||
</picture>
|
||||
@@ -14,17 +14,25 @@ Like the cactus, great events bloom under any condition when managed with care.
|
||||
|
||||
#### What is it?
|
||||
|
||||
A mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required.
|
||||
A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency.
|
||||
- **🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
|
||||
- **🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place.
|
||||
- **📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
|
||||
- **👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers.
|
||||
- **🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
|
||||
- **✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go.
|
||||
**🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency.
|
||||
|
||||
**🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
|
||||
|
||||
**🌐 Federation** - Connect with other Cactoide instances to discover events across the network. Share your public events and creating a decentralized event discovery network.
|
||||
|
||||
**🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place.
|
||||
|
||||
**📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
|
||||
|
||||
**👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers.
|
||||
|
||||
**🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
|
||||
|
||||
**✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go.
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -55,6 +63,53 @@ Your app will be available at `http://localhost:5173`. You can use the Makefile
|
||||
|
||||
Use the `database/seed.sql` if you want to populate your database with dummy data.
|
||||
|
||||
### Federation
|
||||
|
||||
Cactoide supports federation, allowing multiple instances to share and discover public events across the network. This enables users to discover events from other Cactoide instances, creating a decentralized event discovery network.
|
||||
|
||||
<p align="center">
|
||||
<img alt="Federation Example" src="./docs/federation_example.png" width="840">
|
||||
</p>
|
||||
|
||||
#### How it works
|
||||
|
||||
Federation is managed through the `federation.config.js` file, which contains:
|
||||
|
||||
- **Instance name**: The display name for your instance when exposing events to the federation
|
||||
- **Instance list**: An array of federated instance URLs. Add instance URLs here to discover events from other federated instances.
|
||||
|
||||
```javascript
|
||||
const config = {
|
||||
name: 'Cactoide Genesis',
|
||||
instances: [{ url: 'js-meetups.seattle.io' }, { url: 'ai-events.seattle.com' }]
|
||||
};
|
||||
```
|
||||
|
||||
#### Opt-in
|
||||
|
||||
To enable federation on your instance, you need to:
|
||||
|
||||
1. **Set the environment variable**: Add `FEDERATION_INSTANCE=true` to your `.env` file. This enables the federation API endpoints on your instance.
|
||||
|
||||
2. **Configure your instance name**: Update the `name` field in your `federation.config.js` file to set your instance's display name.
|
||||
|
||||
Your instance will automatically expose:
|
||||
|
||||
- `/api/federation/events` - Returns all public events from your instance
|
||||
- `/api/federation/info` - Returns your instance name and public events count
|
||||
|
||||
#### Adding your instance
|
||||
|
||||
To add your instance to the global federation list (so other instances can discover your events):
|
||||
|
||||
1. Fork the [Cactoide repository](https://github.com/polaroi8d/cactoide)
|
||||
2. Add your instance URL to the `instances` array in [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js):
|
||||
3. Open a pull request to the main repository
|
||||
|
||||
Once merged, your instance will appear in the federation network, and other instances will be able to discover and display your public events.
|
||||
|
||||
You can view all registered federated instances in the main repository: [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js) file.
|
||||
|
||||
### Options
|
||||
|
||||
#### 1. Landing page option
|
||||
@@ -90,7 +145,6 @@ It isn’t backed by a big company. Development depends on the support and gener
|
||||
|
||||
You can support in a few ways:
|
||||
|
||||
- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben)
|
||||
- Reach me directly: leventeorb[@]gmail.com
|
||||
|
||||
If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community.
|
||||
|
||||
BIN
docs/federation_example.png
Normal file
BIN
docs/federation_example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
19
federation.config.js
Normal file
19
federation.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const config = {
|
||||
name: 'Cactoide Genesis',
|
||||
instances: [
|
||||
{
|
||||
url: 'cactoide.org'
|
||||
},
|
||||
{
|
||||
url: 'cactoide.dalev.hu'
|
||||
},
|
||||
{
|
||||
url: 'localhost:5174'
|
||||
},
|
||||
{
|
||||
url: 'localhost:5175'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -24,12 +24,8 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
<<<<<<< HEAD
|
||||
"drizzle-kit": "^0.31.5",
|
||||
=======
|
||||
"@types/node": "^24.9.1",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
>>>>>>> 222c2ee (feat: add pino logger for serverside)
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
@@ -2108,8 +2104,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||
@@ -2121,7 +2115,6 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
>>>>>>> 222c2ee (feat: add pino logger for serverside)
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
|
||||
@@ -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'}
|
||||
|
||||
146
src/lib/fetchFederatedEvents.ts
Normal file
146
src/lib/fetchFederatedEvents.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logger } from '$lib/logger';
|
||||
import type { Event } from '$lib/types';
|
||||
|
||||
import config from '../../federation.config.js';
|
||||
|
||||
console.log(config.instances);
|
||||
|
||||
interface FederationConfig {
|
||||
name: string;
|
||||
instances: Array<{ url: string }>;
|
||||
}
|
||||
|
||||
interface FederationEventsResponse {
|
||||
events: Array<Event & { federation?: boolean }>;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the federation config file
|
||||
*/
|
||||
async function readFederationConfig(): Promise<FederationConfig | null> {
|
||||
try {
|
||||
const configPath = join(process.cwd(), 'federation.config.js');
|
||||
|
||||
// Use dynamic import to load the config file as a module
|
||||
// This is safer than eval and works with ES modules
|
||||
const configModule = await import(configPath + '?t=' + Date.now());
|
||||
const config = (configModule.default || configModule.config) as FederationConfig;
|
||||
|
||||
if (config && config.instances && Array.isArray(config.instances)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
logger.warn('Invalid federation config structure');
|
||||
return null;
|
||||
} catch (error) {
|
||||
// If dynamic import fails, try reading as text and parsing
|
||||
try {
|
||||
const configPath = join(process.cwd(), 'federation.config.js');
|
||||
const configContent = readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Try to extract JSON-like structure
|
||||
const configMatch = configContent.match(/instances:\s*\[([\s\S]*?)\]/);
|
||||
if (configMatch) {
|
||||
// Simple parsing - extract URLs
|
||||
const urlMatches = configContent.matchAll(/url:\s*['"]([^'"]+)['"]/g);
|
||||
const instances = Array.from(urlMatches, (match) => ({ url: match[1] }));
|
||||
|
||||
if (instances.length > 0) {
|
||||
return {
|
||||
name: 'Federated Instances',
|
||||
instances
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
logger.error({ error: fallbackError }, 'Error parsing federation.config.js as fallback');
|
||||
}
|
||||
|
||||
logger.error({ error }, 'Error reading federation.config.js');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches events from a single federated instance
|
||||
*/
|
||||
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
|
||||
try {
|
||||
// Ensure URL has protocol and append /api/federation/events
|
||||
|
||||
const apiUrl = `http://${instanceUrl}/api/federation/events`;
|
||||
|
||||
logger.debug({ apiUrl }, 'Fetching events from federated instance');
|
||||
|
||||
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 events from instance');
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FederationEventsResponse;
|
||||
|
||||
if (!data.events || !Array.isArray(data.events)) {
|
||||
logger.warn({ apiUrl }, 'Invalid events response structure');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Mark events as federated and add source URL
|
||||
const federatedEvents: Event[] = data.events.map((event) => ({
|
||||
...event,
|
||||
federation: true,
|
||||
federation_url: `http://${instanceUrl}`
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
{ apiUrl, eventCount: federatedEvents.length },
|
||||
'Successfully fetched federated events'
|
||||
);
|
||||
return federatedEvents;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
'Error fetching events from instance'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches events from all configured federated instances
|
||||
*/
|
||||
export async function fetchAllFederatedEvents(): Promise<Event[]> {
|
||||
const config = await readFederationConfig();
|
||||
|
||||
if (!config || !config.instances || config.instances.length === 0) {
|
||||
logger.debug('No federation config or instances found');
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ instanceCount: config.instances.length },
|
||||
'Fetching events from federated instances'
|
||||
);
|
||||
|
||||
// Fetch from all instances in parallel
|
||||
const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url));
|
||||
|
||||
const results = await Promise.all(fetchPromises);
|
||||
|
||||
// Flatten all events into a single array
|
||||
const allFederatedEvents = results.flat();
|
||||
|
||||
logger.info({ totalEvents: allFederatedEvents.length }, 'Completed fetching federated events');
|
||||
|
||||
return allFederatedEvents;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const generateUserId = () => {
|
||||
const secureRandomString = randomBytes(8).toString('base36').substr(0, 9);
|
||||
const userId = 'user_' + Date.now() + '_' + secureRandomString;
|
||||
const userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
return userId;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"inviteOnly": "Invite Only",
|
||||
"invite-only": "Invite Only",
|
||||
"limited": "Limited",
|
||||
"unlimited": "Unlimited",
|
||||
"capacity": "Capacity",
|
||||
@@ -98,7 +99,8 @@
|
||||
"home": "Home",
|
||||
"discover": "Discover",
|
||||
"create": "Create",
|
||||
"myEvents": "My Events"
|
||||
"myEvents": "My Events",
|
||||
"instance": "Instance"
|
||||
},
|
||||
"home": {
|
||||
"title": "Cactoide - The RSVP site",
|
||||
@@ -249,6 +251,21 @@
|
||||
"noEventsFoundTitle": "No events found",
|
||||
"noEventsFoundDescription": "Try adjusting your search terms or browse all events"
|
||||
},
|
||||
"instance": {
|
||||
"name": "Name",
|
||||
"url": "URL",
|
||||
"events": "Events",
|
||||
"healthStatus": "Health Status",
|
||||
"responseTime": "Response Time",
|
||||
"notAvailable": "N/A",
|
||||
"healthStatusHealthy": "healthy",
|
||||
"healthStatusUnhealthy": "unhealthy",
|
||||
"healthStatusUnknown": "unknown",
|
||||
"description": "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",
|
||||
"configFile": "federation.config.js",
|
||||
"file": "file.",
|
||||
"noInstances": "No federation instances configured."
|
||||
},
|
||||
"calendar": {
|
||||
"addToCalendarTitle": "Add to Calendar",
|
||||
"googleCalendarTitle": "Google Calendar",
|
||||
@@ -267,7 +284,7 @@
|
||||
"layout": {
|
||||
"defaultTitle": "Cactoide -",
|
||||
"defaultDescription": "Create and manage event RSVPs",
|
||||
"userIdCookieText": "Your UserID storated as a cookie:",
|
||||
"userIdCookieText": "Your UserID stored as a cookie:",
|
||||
"firstTimeVisiting": "First time visiting. Generating new UserID...",
|
||||
"copyright": "© 2025 Cactoide"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import pino from 'pino';
|
||||
import { LOG_PRETTY, LOG_LEVEL } from '$env/static/private';
|
||||
|
||||
try {
|
||||
if (LOG_PRETTY && LOG_LEVEL) {
|
||||
console.debug(
|
||||
`Initializing logger with pretty logs: LOG_PRETTY: ${LOG_PRETTY} and LOG_LEVEL: ${LOG_LEVEL}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing logger', error);
|
||||
}
|
||||
|
||||
const USE_PRETTY_LOGS = LOG_PRETTY === 'true';
|
||||
|
||||
const transport = USE_PRETTY_LOGS
|
||||
@@ -10,6 +20,14 @@ const transport = USE_PRETTY_LOGS
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname'
|
||||
},
|
||||
customLevels: {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Event {
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
federation?: boolean; // Optional: true if event is from a federated instance
|
||||
federation_url?: string; // Optional: URL of the federated instance this event came from
|
||||
}
|
||||
|
||||
export interface RSVP {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Error Content -->
|
||||
<div class="container mx-auto flex-1 px-4 py-8">
|
||||
<div class="mx-auto max-w-md text-center">
|
||||
<div class="rounded-sm border border-red-500/30 bg-red-900/20 p-8">
|
||||
<div class="rounded-sm border border-red-500/30 bg-red-900 p-8">
|
||||
<div class="mb-4 text-6xl text-red-400">🚨</div>
|
||||
<h2 class="mb-4 text-2xl font-bold text-red-400">{t('errors.errorTitle')}</h2>
|
||||
|
||||
|
||||
55
src/routes/api/federation/events/+server.ts
Normal file
55
src/routes/api/federation/events/+server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { database } from '$lib/database/db';
|
||||
import { events } from '$lib/database/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { logger } from '$lib/logger';
|
||||
|
||||
import { FEDERATION_INSTANCE } from '$env/static/private';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
if (!FEDERATION_INSTANCE) {
|
||||
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Fetch all public and invite-only events ordered by creation date (newest first)
|
||||
const publicEvents = await database
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.visibility, 'public'))
|
||||
.orderBy(desc(events.createdAt));
|
||||
|
||||
// Transform events to include federation_event type
|
||||
const transformedEvents = publicEvents.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
date: event.date,
|
||||
time: event.time,
|
||||
location: event.location,
|
||||
location_type: event.locationType,
|
||||
location_url: event.locationUrl,
|
||||
type: event.type,
|
||||
federation: true,
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId,
|
||||
created_at: event.createdAt?.toISOString() || '',
|
||||
updated_at: event.updatedAt?.toISOString() || ''
|
||||
}));
|
||||
|
||||
return json({
|
||||
events: transformedEvents,
|
||||
count: transformedEvents.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error fetching events from API');
|
||||
return json(
|
||||
{
|
||||
error: 'Failed to fetch events',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
38
src/routes/api/federation/info/+server.ts
Normal file
38
src/routes/api/federation/info/+server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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';
|
||||
|
||||
import { FEDERATION_INSTANCE } from '$env/static/private';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
if (!FEDERATION_INSTANCE) {
|
||||
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
|
||||
}
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
28
src/routes/api/healthz/+server.ts
Normal file
28
src/routes/api/healthz/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// src/routes/healthz/+server.ts
|
||||
import { json } from '@sveltejs/kit';
|
||||
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`);
|
||||
const responseTime = Math.round(performance.now() - startTime);
|
||||
return json(
|
||||
{ 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' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,11 @@
|
||||
bind:value={eventData.date}
|
||||
min={today}
|
||||
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
||||
on:keydown={(e) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{#if errors.date}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { desc, inArray } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { events } from '$lib/database/schema';
|
||||
import { logger } from '$lib/logger';
|
||||
import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
// Fetch all non-private events (public and invite-only) ordered by creation date (newest first)
|
||||
// Fetch all non-private events ordered by creation date (newest first)
|
||||
const publicEvents = await database
|
||||
.select()
|
||||
.from(events)
|
||||
@@ -17,24 +18,36 @@ export const load: PageServerLoad = async () => {
|
||||
const transformedEvents = publicEvents.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
date: event.date, // Already in 'YYYY-MM-DD' format
|
||||
time: event.time, // Already in 'HH:MM:SS' format
|
||||
date: event.date,
|
||||
time: event.time,
|
||||
location: event.location,
|
||||
location_type: event.locationType,
|
||||
location_url: event.locationUrl,
|
||||
type: event.type,
|
||||
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
|
||||
attendee_limit: event.attendeeLimit,
|
||||
visibility: event.visibility,
|
||||
user_id: event.userId, // Note: schema uses camelCase
|
||||
user_id: event.userId,
|
||||
created_at: event.createdAt?.toISOString(),
|
||||
updated_at: event.updatedAt?.toISOString()
|
||||
updated_at: event.updatedAt?.toISOString(),
|
||||
federation: false // Add false for local events
|
||||
}));
|
||||
|
||||
// Fetch federated events from federation.config.js
|
||||
let federatedEvents: typeof transformedEvents = [];
|
||||
try {
|
||||
federatedEvents = await fetchAllFederatedEvents();
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error fetching federated events, continuing with local events only');
|
||||
}
|
||||
|
||||
// Merge local and federated events
|
||||
const allEvents = [...transformedEvents, ...federatedEvents];
|
||||
|
||||
return {
|
||||
events: transformedEvents
|
||||
events: allEvents
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error loading public events');
|
||||
logger.error({ error }, 'Error loading events');
|
||||
|
||||
// Return empty array on error to prevent page crash
|
||||
return {
|
||||
|
||||
@@ -267,9 +267,15 @@
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredEvents as event, i (i)}
|
||||
<div class="rounded-sm border border-slate-200 p-6 shadow-sm">
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
|
||||
{@const isFederated = event.federation === true}
|
||||
<div
|
||||
class="flex flex-col rounded-sm border border-slate-200 bg-slate-800/50
|
||||
p-6 shadow-sm"
|
||||
>
|
||||
<div class="mb-4 flex-1">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-slate-300">{event.name}</h3>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm text-slate-500">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -314,36 +320,58 @@
|
||||
<span>{event.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
|
||||
</span>
|
||||
</div>
|
||||
{#if isFederated && event.federation_url}
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border border-blue-500 px-2 py-1 text-xs
|
||||
font-medium text-blue-500"
|
||||
>
|
||||
{event.federation_url}
|
||||
</span>
|
||||
</div>{:else}
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public'
|
||||
? t('common.public')
|
||||
: t('common.inviteOnly')}
|
||||
</span>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button
|
||||
on:click={() => goto(`/event/${event.id}`)}
|
||||
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
|
||||
>
|
||||
{t('discover.viewButton')}
|
||||
</button>
|
||||
<div class="mt-auto flex">
|
||||
{#if isFederated && event.federation_url}
|
||||
<a
|
||||
href="{event.federation_url}/event/{event.id}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 rounded-sm border-2 border-blue-500 bg-blue-400/20 px-4 py-2 text-center font-semibold duration-200 hover:bg-blue-400/70"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => goto(`/event/${event.id}`)}
|
||||
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
|
||||
>
|
||||
{t('discover.viewButton')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
? 'border-green-300 text-green-400'
|
||||
: 'border-orange-300 text-orange-400'}"
|
||||
>
|
||||
{event.visibility === 'public' ? t('common.public') : t('common.private')}
|
||||
<!-- TODO(polaroi8d): replace with something better solution; message.json using this, beacuse of common.invite-only works here -->
|
||||
{t(`common.${event.visibility}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2"></div>
|
||||
|
||||
@@ -104,14 +104,15 @@ export const actions: Actions = {
|
||||
// Get current RSVPs
|
||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||
|
||||
// Calculate total attendees (including guests)
|
||||
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
||||
// Calculate remaining spots and ensure main attendee + guests fit
|
||||
const newAttendeesCount = 1 + numberOfGuests;
|
||||
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||
|
||||
// Check if event is full (for limited type events)
|
||||
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||
if (totalAttendees > eventData.attendeeLimit) {
|
||||
if (newAttendeesCount > remainingSpots) {
|
||||
return fail(400, {
|
||||
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.`
|
||||
error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} remain.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,19 +485,19 @@
|
||||
{#if success}
|
||||
{#if typeToShow === 'add'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900 p-4 text-green-400"
|
||||
>
|
||||
{success}
|
||||
</div>
|
||||
{:else if typeToShow === 'remove'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900 p-4 text-yellow-400"
|
||||
>
|
||||
{t('event.removedRsvpSuccessfully')}
|
||||
</div>
|
||||
{:else if typeToShow === 'copy'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900 p-4 text-yellow-400"
|
||||
>
|
||||
{t('event.eventLinkCopied')}
|
||||
</div>
|
||||
@@ -508,7 +508,7 @@
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900/20 p-4 text-red-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900 p-4 text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const actions: Actions = {
|
||||
const date = formData.get('date') as string;
|
||||
const time = formData.get('time') as string;
|
||||
const location = formData.get('location') as string;
|
||||
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
|
||||
const locationType = formData.get('locationType') as string;
|
||||
const locationUrl = formData.get('location_url') as string;
|
||||
const type = formData.get('type') as 'limited' | 'unlimited';
|
||||
const attendeeLimit = formData.get('attendee_limit') as string;
|
||||
@@ -109,7 +109,7 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if date is in the past using local timezone (but allow editing past events for corrections)
|
||||
// Check if date is in the past using local timezone
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
const eventDate = new Date(year, month - 1, day);
|
||||
const today = new Date();
|
||||
@@ -148,7 +148,6 @@ export const actions: Actions = {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update the event
|
||||
await database
|
||||
.update(events)
|
||||
@@ -157,7 +156,7 @@ export const actions: Actions = {
|
||||
date: date,
|
||||
time: time,
|
||||
location: location?.trim() || '',
|
||||
locationType: locationType,
|
||||
locationType: locationType as 'none' | 'text' | 'maps',
|
||||
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
|
||||
type: type,
|
||||
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { EventType, LocationType } from '$lib/types';
|
||||
import type { CreateEventData, EventType, LocationType } from '$lib/types';
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n/i18n.js';
|
||||
@@ -7,7 +7,7 @@
|
||||
export let data;
|
||||
export let form;
|
||||
|
||||
let eventData = {
|
||||
let eventData: CreateEventData = {
|
||||
name: data.event.name,
|
||||
date: data.event.date,
|
||||
time: data.event.time,
|
||||
@@ -15,7 +15,7 @@
|
||||
location_type: data.event.locationType || 'none',
|
||||
location_url: data.event.locationUrl || '',
|
||||
type: data.event.type,
|
||||
attendee_limit: data.event.attendeeLimit,
|
||||
attendee_limit: data.event.attendeeLimit || undefined,
|
||||
visibility: data.event.visibility
|
||||
};
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
attendee_limit: (values as any).attendee_limit
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parseInt(String((values as any).attendee_limit))
|
||||
: null
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const handleTypeChange = (type: EventType) => {
|
||||
eventData.type = type;
|
||||
if (type === 'unlimited') {
|
||||
eventData.attendee_limit = null;
|
||||
eventData.attendee_limit = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
bind:value={eventData.date}
|
||||
min={today}
|
||||
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
||||
on:keydown={(e) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{#if errors.date}
|
||||
@@ -189,6 +194,9 @@
|
||||
|
||||
<!-- Location Type -->
|
||||
<div>
|
||||
<!-- Hidden input to submit locationType value -->
|
||||
<input type="hidden" name="locationType" bind:value={eventData.location_type} />
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||
{t('create.locationTypeLabel')}
|
||||
@@ -279,6 +287,9 @@
|
||||
|
||||
<!-- Event Type -->
|
||||
<div>
|
||||
<!-- Hidden input to submit type value -->
|
||||
<input type="hidden" name="type" bind:value={eventData.type} />
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||
{t('common.type')} <span class="text-red-400">{t('common.required')}</span>
|
||||
@@ -333,6 +344,9 @@
|
||||
|
||||
<!-- Event Visibility -->
|
||||
<div>
|
||||
<!-- Hidden input to submit visibility value -->
|
||||
<input type="hidden" name="visibility" bind:value={eventData.visibility} />
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
|
||||
@@ -444,7 +458,7 @@
|
||||
<!-- Invite Link Toast -->
|
||||
{#if showInviteLinkToast}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900 p-4 text-yellow-400"
|
||||
>
|
||||
{t('event.inviteLinkCopied')}
|
||||
</div>
|
||||
|
||||
@@ -129,14 +129,15 @@ export const actions: Actions = {
|
||||
// Get current RSVPs
|
||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||
|
||||
// Calculate total attendees (including guests)
|
||||
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
||||
// Calculate remaining spots and ensure main attendee + guests fit
|
||||
const newAttendeesCount = 1 + numberOfGuests;
|
||||
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||
|
||||
// Check if event is full (for limited type events)
|
||||
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||
if (totalAttendees > eventData.attendeeLimit) {
|
||||
if (newAttendeesCount > remainingSpots) {
|
||||
return fail(400, {
|
||||
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.`
|
||||
error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} remain.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +450,13 @@
|
||||
{#if success}
|
||||
{#if form?.type === 'add'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500 bg-green-900 p-4 text-green-400"
|
||||
>
|
||||
{success}
|
||||
</div>
|
||||
{:else if form?.type === 'remove'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500 bg-yellow-900 p-4 text-yellow-400"
|
||||
>
|
||||
{t('event.removedRsvpSuccessfully')}
|
||||
</div>
|
||||
@@ -465,7 +465,7 @@
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900/20 p-4 text-red-400"
|
||||
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500 bg-red-900 p-4 text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// src/routes/healthz/+server.ts
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { database } from '$lib/database/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await database.execute(sql`select 1`);
|
||||
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
||||
} catch (err) {
|
||||
return json(
|
||||
{ ok: false, error: (err as Error)?.message, message: 'Database unreachable.' },
|
||||
{ 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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
148
src/routes/instance/+page.svelte
Normal file
148
src/routes/instance/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<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 t('instance.notAvailable');
|
||||
return `${responseTime} ms`;
|
||||
}
|
||||
|
||||
function getHealthStatusText(status: 'healthy' | 'unhealthy' | 'unknown'): string {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return t('instance.healthStatusHealthy');
|
||||
case 'unhealthy':
|
||||
return t('instance.healthStatusUnhealthy');
|
||||
case 'unknown':
|
||||
return t('instance.healthStatusUnknown');
|
||||
}
|
||||
}
|
||||
</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"
|
||||
>
|
||||
{t('instance.name')}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||
>
|
||||
{t('instance.url')}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||
>
|
||||
{t('instance.events')}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||
>
|
||||
{t('instance.healthStatus')}
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||
>
|
||||
{t('instance.responseTime')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-700">
|
||||
{#each data.instances as instance, i (i)}
|
||||
<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 || t('instance.notAvailable')}
|
||||
</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 : t('instance.notAvailable')}
|
||||
</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={getHealthStatusText(instance.healthStatus)}
|
||||
></span>
|
||||
<span class="text-sm text-slate-300 capitalize">
|
||||
{getHealthStatusText(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">
|
||||
{t('instance.description')}
|
||||
<a
|
||||
href="https://github.com/cactoide/cactoide/blob/main/federation.config.js"
|
||||
class="text-violet-300/80">{t('instance.configFile')}</a
|
||||
>
|
||||
{t('instance.file')}
|
||||
</p>
|
||||
|
||||
{#if data.instances.length === 0}
|
||||
<div class="py-8 text-center text-slate-500">{t('instance.noInstances')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Additional styles if needed */
|
||||
</style>
|
||||
Reference in New Issue
Block a user