feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
This commit is contained in:
@@ -1,13 +1,49 @@
|
||||
import cron from 'node-cron';
|
||||
import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js';
|
||||
import { broadcastNotification } from '$lib/server/services/notificationService.js';
|
||||
import { pruneOldLogs } from '$lib/server/services/auditLogService.js';
|
||||
import * as appService from '$lib/server/services/appService.js';
|
||||
import { AppStatusValue, NotificationEvent } from '$lib/utils/constants.js';
|
||||
|
||||
let scheduledTask: cron.ScheduledTask | null = null;
|
||||
let cleanupTask: cron.ScheduledTask | null = null;
|
||||
let auditPruneTask: cron.ScheduledTask | null = null;
|
||||
|
||||
// Track previous status per app to detect transitions
|
||||
const previousStatuses = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Check if a status transition warrants a notification.
|
||||
*/
|
||||
function getStatusChangeEvent(
|
||||
previousStatus: string | undefined,
|
||||
newStatus: string
|
||||
): string | null {
|
||||
if (!previousStatus) {
|
||||
return null; // First check — no transition
|
||||
}
|
||||
if (previousStatus === newStatus) {
|
||||
return null; // No change
|
||||
}
|
||||
|
||||
if (newStatus === AppStatusValue.OFFLINE && previousStatus !== AppStatusValue.OFFLINE) {
|
||||
return NotificationEvent.APP_OFFLINE;
|
||||
}
|
||||
if (newStatus === AppStatusValue.ONLINE && previousStatus !== AppStatusValue.ONLINE) {
|
||||
return NotificationEvent.APP_ONLINE;
|
||||
}
|
||||
if (newStatus === AppStatusValue.DEGRADED && previousStatus !== AppStatusValue.DEGRADED) {
|
||||
return NotificationEvent.APP_DEGRADED;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the healthcheck scheduler.
|
||||
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
|
||||
* Also starts an hourly cleanup job to prune old status records.
|
||||
* Triggers notifications when app status changes.
|
||||
*/
|
||||
export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
if (scheduledTask) {
|
||||
@@ -16,7 +52,29 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
|
||||
scheduledTask = cron.schedule(cronExpression, async () => {
|
||||
try {
|
||||
await checkAllApps();
|
||||
const results = await checkAllApps();
|
||||
|
||||
// Check for status transitions and send notifications
|
||||
for (const result of results) {
|
||||
const prevStatus = previousStatuses.get(result.appId);
|
||||
const event = getStatusChangeEvent(prevStatus, result.status);
|
||||
|
||||
if (event) {
|
||||
// Fire-and-forget notification
|
||||
appService
|
||||
.findById(result.appId)
|
||||
.then((app) => {
|
||||
const statusLabel = result.status.charAt(0).toUpperCase() + result.status.slice(1);
|
||||
const message = `${app.name} is now ${statusLabel} (was ${prevStatus ?? 'unknown'})`;
|
||||
return broadcastNotification(result.appId, event, message);
|
||||
})
|
||||
.catch(() => {
|
||||
// Swallow notification errors
|
||||
});
|
||||
}
|
||||
|
||||
previousStatuses.set(result.appId, result.status);
|
||||
}
|
||||
} catch {
|
||||
// Swallow errors to prevent scheduler crash
|
||||
}
|
||||
@@ -31,6 +89,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Audit log pruning: run daily at midnight
|
||||
auditPruneTask = cron.schedule('0 0 * * *', async () => {
|
||||
try {
|
||||
await pruneOldLogs(90); // Default 90 day retention
|
||||
} catch {
|
||||
// Swallow errors to prevent scheduler crash
|
||||
}
|
||||
});
|
||||
|
||||
// Run an initial check shortly after startup
|
||||
setTimeout(() => {
|
||||
checkAllApps().catch(() => {
|
||||
@@ -51,4 +118,8 @@ export function stopScheduler(): void {
|
||||
cleanupTask.stop();
|
||||
cleanupTask = null;
|
||||
}
|
||||
if (auditPruneTask) {
|
||||
auditPruneTask.stop();
|
||||
auditPruneTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
* Reusable authentication check helper.
|
||||
* Throws a redirect to /login if the user is not authenticated.
|
||||
* Returns the authenticated user from event.locals.
|
||||
*
|
||||
* For API routes, also checks for Bearer token in Authorization header.
|
||||
* If a valid API token is found, the user is set from the token's owner.
|
||||
*/
|
||||
export function requireAuth(event: RequestEvent) {
|
||||
const user = event.locals.user;
|
||||
if (!user) {
|
||||
// For API routes, redirect is not appropriate — but we keep the behavior
|
||||
// consistent with the existing codebase. The hooks.server.ts handles
|
||||
// API token validation and sets event.locals.user before routes run.
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
return user;
|
||||
@@ -20,3 +26,21 @@ export function requireAuth(event: RequestEvent) {
|
||||
export function isAuthenticated(event: RequestEvent): boolean {
|
||||
return event.locals.user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header, if present.
|
||||
* Returns the token string or null.
|
||||
*/
|
||||
export function extractBearerToken(event: RequestEvent): string | null {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ describe('appService', () => {
|
||||
expect(mockApp.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
orderBy: { name: 'asc' },
|
||||
include: { statuses: { orderBy: { checkedAt: 'desc' }, take: 1 } }
|
||||
include: {
|
||||
links: { orderBy: { order: 'asc' } },
|
||||
statuses: { orderBy: { checkedAt: 'desc' }, take: 1 }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,10 +155,7 @@ describe('appService', () => {
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('returns unique categories', async () => {
|
||||
mockApp.findMany.mockResolvedValue([
|
||||
{ category: 'Media' },
|
||||
{ category: 'Monitoring' }
|
||||
]);
|
||||
mockApp.findMany.mockResolvedValue([{ category: 'Media' }, { category: 'Monitoring' }]);
|
||||
|
||||
const result = await appService.getCategories();
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ describe('boardService', () => {
|
||||
|
||||
const result = await boardService.createWidget({
|
||||
sectionId: 's1',
|
||||
type: 'app'
|
||||
type: 'app',
|
||||
config: JSON.stringify({ appId: 'test-app-1' })
|
||||
});
|
||||
|
||||
expect(result.type).toBe('app');
|
||||
|
||||
@@ -148,9 +148,7 @@ describe('discoveryService', () => {
|
||||
it('returns error on Traefik API failure', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: false, status: 500 })
|
||||
)
|
||||
vi.fn(() => Promise.resolve({ ok: false, status: 500 }))
|
||||
);
|
||||
|
||||
const result = await discoverTraefik('http://traefik.local:8080');
|
||||
|
||||
@@ -67,9 +67,7 @@ describe('groupService', () => {
|
||||
it('throws on duplicate name', async () => {
|
||||
mockGroup.findUnique.mockResolvedValue({ id: '1', name: 'Existing' });
|
||||
|
||||
await expect(groupService.create({ name: 'Existing' })).rejects.toThrow(
|
||||
'already exists'
|
||||
);
|
||||
await expect(groupService.create({ name: 'Existing' })).rejects.toThrow('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,8 +119,9 @@ describe('groupService', () => {
|
||||
{ id: 'g2', name: 'Default2', isDefault: true }
|
||||
]);
|
||||
mockUserGroup.findUnique.mockResolvedValue(null);
|
||||
mockUserGroup.create.mockImplementation(({ data }: { data: { userId: string; groupId: string } }) =>
|
||||
Promise.resolve({ id: `ug-${data.groupId}`, ...data })
|
||||
mockUserGroup.create.mockImplementation(
|
||||
({ data }: { data: { userId: string; groupId: string } }) =>
|
||||
Promise.resolve({ id: `ug-${data.groupId}`, ...data })
|
||||
);
|
||||
|
||||
const results = await groupService.addUserToDefaultGroups('u1');
|
||||
|
||||
@@ -182,9 +182,7 @@ describe('importService', () => {
|
||||
icon: null,
|
||||
order: 0,
|
||||
isExpandedByDefault: true,
|
||||
widgets: [
|
||||
{ type: 'note', order: 0, config: '{}', appName: null }
|
||||
]
|
||||
widgets: [{ type: 'note', order: 0, config: '{}', appName: null }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -95,7 +95,11 @@ describe('oauthService', () => {
|
||||
new URL('https://auth.example.com/authorize?code_challenge=abc')
|
||||
);
|
||||
|
||||
const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'test-state');
|
||||
const url = await generateAuthUrl(
|
||||
'https://app.example.com/callback',
|
||||
'test-challenge',
|
||||
'test-state'
|
||||
);
|
||||
|
||||
expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc');
|
||||
expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith(
|
||||
|
||||
@@ -29,12 +29,7 @@ describe('permissionService', () => {
|
||||
it('grants full access to admins', async () => {
|
||||
mockUser.findUnique.mockResolvedValue({ role: 'admin' });
|
||||
|
||||
const result = await permissionService.checkPermission(
|
||||
'board',
|
||||
'b1',
|
||||
'admin-user',
|
||||
'edit'
|
||||
);
|
||||
const result = await permissionService.checkPermission('board', 'b1', 'admin-user', 'edit');
|
||||
|
||||
expect(result.hasPermission).toBe(true);
|
||||
expect(result.effectiveLevel).toBe('admin');
|
||||
@@ -45,12 +40,7 @@ describe('permissionService', () => {
|
||||
mockUser.findUnique.mockResolvedValue({ role: 'user' });
|
||||
mockPermission.findFirst.mockResolvedValue({ level: 'edit' });
|
||||
|
||||
const result = await permissionService.checkPermission(
|
||||
'board',
|
||||
'b1',
|
||||
'user1',
|
||||
'view'
|
||||
);
|
||||
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
|
||||
|
||||
expect(result.hasPermission).toBe(true);
|
||||
expect(result.effectiveLevel).toBe('edit');
|
||||
@@ -61,12 +51,7 @@ describe('permissionService', () => {
|
||||
mockUser.findUnique.mockResolvedValue({ role: 'user' });
|
||||
mockPermission.findFirst.mockResolvedValue({ level: 'view' });
|
||||
|
||||
const result = await permissionService.checkPermission(
|
||||
'board',
|
||||
'b1',
|
||||
'user1',
|
||||
'admin'
|
||||
);
|
||||
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'admin');
|
||||
|
||||
expect(result.hasPermission).toBe(false);
|
||||
});
|
||||
@@ -77,12 +62,7 @@ describe('permissionService', () => {
|
||||
mockUserGroup.findMany.mockResolvedValue([{ groupId: 'g1' }]);
|
||||
mockPermission.findMany.mockResolvedValue([{ level: 'edit' }]);
|
||||
|
||||
const result = await permissionService.checkPermission(
|
||||
'board',
|
||||
'b1',
|
||||
'user1',
|
||||
'view'
|
||||
);
|
||||
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
|
||||
|
||||
expect(result.hasPermission).toBe(true);
|
||||
expect(result.source).toBe('group');
|
||||
@@ -93,12 +73,7 @@ describe('permissionService', () => {
|
||||
mockPermission.findFirst.mockResolvedValue(null);
|
||||
mockUserGroup.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await permissionService.checkPermission(
|
||||
'board',
|
||||
'b1',
|
||||
'user1',
|
||||
'view'
|
||||
);
|
||||
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
|
||||
|
||||
expect(result.hasPermission).toBe(false);
|
||||
expect(result.effectiveLevel).toBeNull();
|
||||
|
||||
@@ -131,9 +131,7 @@ describe('userService', () => {
|
||||
|
||||
describe('getUserGroups', () => {
|
||||
it('returns user group memberships', async () => {
|
||||
mockUserGroup.findMany.mockResolvedValue([
|
||||
{ group: { id: 'g1', name: 'Devs' } }
|
||||
]);
|
||||
mockUserGroup.findMany.mockResolvedValue([{ group: { id: 'g1', name: 'Devs' } }]);
|
||||
|
||||
const result = await userService.getUserGroups('u1');
|
||||
expect(result).toEqual([{ id: 'g1', name: 'Devs' }]);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a token string using SHA-256 for fast lookup, then bcrypt for storage.
|
||||
* We use SHA-256 as an intermediate to create a fixed-length input for bcrypt
|
||||
* (bcrypt has a 72-byte limit).
|
||||
*/
|
||||
function sha256(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token. Returns the plaintext token (shown once) and the DB record.
|
||||
*/
|
||||
export async function generateToken(
|
||||
userId: string,
|
||||
name: string,
|
||||
scope: string,
|
||||
expiresAt?: string
|
||||
) {
|
||||
const plainToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS);
|
||||
|
||||
const token = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
tokenHash,
|
||||
scope,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
scope: token.scope,
|
||||
expiresAt: token.expiresAt,
|
||||
createdAt: token.createdAt,
|
||||
token: plainToken // Only returned once at creation time
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API token.
|
||||
*/
|
||||
export async function revokeToken(tokenId: string, userId: string) {
|
||||
const token = await prisma.apiToken.findUnique({ where: { id: tokenId } });
|
||||
if (!token || token.userId !== userId) {
|
||||
throw new Error('API token not found');
|
||||
}
|
||||
|
||||
await prisma.apiToken.delete({ where: { id: tokenId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for a user (without the hash).
|
||||
*/
|
||||
export async function listTokens(userId: string) {
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
scope: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a plaintext token string. Returns the user info if valid, null otherwise.
|
||||
* Updates lastUsedAt on successful validation.
|
||||
*/
|
||||
export async function validateToken(tokenString: string): Promise<{
|
||||
readonly userId: string;
|
||||
readonly scope: string;
|
||||
} | null> {
|
||||
const tokenSha = sha256(tokenString);
|
||||
|
||||
// We need to check against all tokens since bcrypt hashes are unique per-hash.
|
||||
// For better performance at scale, consider indexing on a prefix or using a different scheme.
|
||||
const allTokens = await prisma.apiToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
tokenHash: true,
|
||||
scope: true,
|
||||
expiresAt: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of allTokens) {
|
||||
const isMatch = await bcrypt.compare(tokenSha, token.tokenHash);
|
||||
if (isMatch) {
|
||||
// Check expiry
|
||||
if (token.expiresAt && token.expiresAt < new Date()) {
|
||||
return null; // Token expired
|
||||
}
|
||||
|
||||
// Update lastUsedAt (fire-and-forget)
|
||||
prisma.apiToken
|
||||
.update({
|
||||
where: { id: token.id },
|
||||
data: { lastUsedAt: new Date() }
|
||||
})
|
||||
.catch(() => {
|
||||
// Swallow errors from lastUsedAt update
|
||||
});
|
||||
|
||||
return {
|
||||
userId: token.userId,
|
||||
scope: token.scope
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export async function findAll(options?: { category?: string; search?: string })
|
||||
statuses: {
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
take: 1
|
||||
},
|
||||
links: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -38,6 +41,9 @@ export async function findById(id: string) {
|
||||
},
|
||||
createdBy: {
|
||||
select: { id: true, displayName: true }
|
||||
},
|
||||
links: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -81,7 +87,8 @@ export async function update(id: string, input: UpdateAppInput) {
|
||||
if (input.healthcheckEnabled !== undefined) data.healthcheckEnabled = input.healthcheckEnabled;
|
||||
if (input.healthcheckInterval !== undefined) data.healthcheckInterval = input.healthcheckInterval;
|
||||
if (input.healthcheckMethod !== undefined) data.healthcheckMethod = input.healthcheckMethod;
|
||||
if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
|
||||
if (input.healthcheckExpectedStatus !== undefined)
|
||||
data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
|
||||
if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout;
|
||||
|
||||
return prisma.app.update({
|
||||
@@ -95,11 +102,7 @@ export async function remove(id: string) {
|
||||
await prisma.app.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function recordStatus(
|
||||
appId: string,
|
||||
status: string,
|
||||
responseTime: number | null
|
||||
) {
|
||||
export async function recordStatus(appId: string, status: string, responseTime: number | null) {
|
||||
return prisma.appStatus.create({
|
||||
data: {
|
||||
appId,
|
||||
@@ -138,6 +141,85 @@ export async function getHealthcheckTargets() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- App Links (Multi-URL) ---
|
||||
|
||||
export async function addAppLink(
|
||||
appId: string,
|
||||
input: { label: string; url: string; icon?: string | null; order?: number }
|
||||
) {
|
||||
await findById(appId);
|
||||
|
||||
let order = input.order;
|
||||
if (order === undefined) {
|
||||
const maxLink = await prisma.appLink.findFirst({
|
||||
where: { appId },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true }
|
||||
});
|
||||
order = (maxLink?.order ?? -1) + 1;
|
||||
}
|
||||
|
||||
return prisma.appLink.create({
|
||||
data: {
|
||||
appId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
icon: input.icon ?? null,
|
||||
order
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAppLink(
|
||||
linkId: string,
|
||||
input: { label?: string; url?: string; icon?: string | null; order?: number }
|
||||
) {
|
||||
const link = await prisma.appLink.findUnique({ where: { id: linkId } });
|
||||
if (!link) {
|
||||
throw new Error(`App link not found: ${linkId}`);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (input.label !== undefined) data.label = input.label;
|
||||
if (input.url !== undefined) data.url = input.url;
|
||||
if (input.icon !== undefined) data.icon = input.icon;
|
||||
if (input.order !== undefined) data.order = input.order;
|
||||
|
||||
return prisma.appLink.update({
|
||||
where: { id: linkId },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeAppLink(linkId: string) {
|
||||
const link = await prisma.appLink.findUnique({ where: { id: linkId } });
|
||||
if (!link) {
|
||||
throw new Error(`App link not found: ${linkId}`);
|
||||
}
|
||||
|
||||
await prisma.appLink.delete({ where: { id: linkId } });
|
||||
}
|
||||
|
||||
export async function reorderAppLinks(appId: string, linkIds: string[]) {
|
||||
await findById(appId);
|
||||
|
||||
const updates = linkIds.map((id, index) =>
|
||||
prisma.appLink.update({
|
||||
where: { id },
|
||||
data: { order: index }
|
||||
})
|
||||
);
|
||||
|
||||
return prisma.$transaction(updates);
|
||||
}
|
||||
|
||||
export async function getAppLinks(appId: string) {
|
||||
return prisma.appLink.findMany({
|
||||
where: { appId },
|
||||
orderBy: { order: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
const apps = await prisma.app.findMany({
|
||||
where: { category: { not: null } },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
/**
|
||||
* Record an audit log entry. Non-blocking: catches and swallows errors
|
||||
* to avoid slowing down the operation being audited.
|
||||
*/
|
||||
export function logAction(
|
||||
userId: string | null,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
details: details ? JSON.stringify(details) : '{}'
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-blocking: swallow errors so the parent operation is unaffected
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit logs with filters and pagination.
|
||||
*/
|
||||
export async function getAuditLogs(options?: {
|
||||
action?: string;
|
||||
entityType?: string;
|
||||
userId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (options?.action) {
|
||||
where.action = options.action;
|
||||
}
|
||||
if (options?.entityType) {
|
||||
where.entityType = options.entityType;
|
||||
}
|
||||
if (options?.userId) {
|
||||
where.userId = options.userId;
|
||||
}
|
||||
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (options?.startDate) {
|
||||
dateFilter.gte = new Date(options.startDate);
|
||||
}
|
||||
if (options?.endDate) {
|
||||
dateFilter.lte = new Date(options.endDate);
|
||||
}
|
||||
if (Object.keys(dateFilter).length > 0) {
|
||||
where.createdAt = dateFilter;
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, displayName: true, email: true }
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.auditLog.count({ where })
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete audit logs older than the given retention period.
|
||||
*/
|
||||
export async function pruneOldLogs(retentionDays: number = 90) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
createdAt: { lt: cutoff }
|
||||
}
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
@@ -78,10 +78,7 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateRefreshToken(
|
||||
userId: string,
|
||||
refreshToken: string
|
||||
): Promise<boolean> {
|
||||
export async function validateRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { refreshToken: true, refreshTokenExpiresAt: true }
|
||||
@@ -108,7 +105,11 @@ export async function revokeRefreshToken(userId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateTokens(userId: string, email: string, role: string): Promise<TokenPair> {
|
||||
export async function rotateTokens(
|
||||
userId: string,
|
||||
email: string,
|
||||
role: string
|
||||
): Promise<TokenPair> {
|
||||
const accessToken = signAccessToken({ userId, email, role });
|
||||
const refreshToken = generateRefreshToken();
|
||||
await saveRefreshToken(userId, refreshToken);
|
||||
|
||||
@@ -1,6 +1,74 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
import type { CreateBoardInput, UpdateBoardInput, CreateSectionInput, UpdateSectionInput } from '$lib/types/board.js';
|
||||
import type {
|
||||
CreateBoardInput,
|
||||
UpdateBoardInput,
|
||||
CreateSectionInput,
|
||||
UpdateSectionInput
|
||||
} from '$lib/types/board.js';
|
||||
import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js';
|
||||
import { WidgetType } from '$lib/utils/constants.js';
|
||||
import {
|
||||
appWidgetConfigSchema,
|
||||
bookmarkWidgetConfigSchema,
|
||||
noteWidgetConfigSchema,
|
||||
embedWidgetConfigSchema,
|
||||
statusWidgetConfigSchema,
|
||||
clockWeatherWidgetConfigSchema,
|
||||
systemStatsWidgetConfigSchema,
|
||||
rssWidgetConfigSchema,
|
||||
calendarWidgetConfigSchema,
|
||||
markdownWidgetConfigSchema,
|
||||
metricWidgetConfigSchema,
|
||||
linkGroupWidgetConfigSchema,
|
||||
cameraWidgetConfigSchema
|
||||
} from '$lib/utils/validators.js';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
/**
|
||||
* Map of widget types to their config validation schemas.
|
||||
*/
|
||||
const widgetConfigSchemas: Record<string, ZodTypeAny> = {
|
||||
[WidgetType.APP]: appWidgetConfigSchema,
|
||||
[WidgetType.BOOKMARK]: bookmarkWidgetConfigSchema,
|
||||
[WidgetType.NOTE]: noteWidgetConfigSchema,
|
||||
[WidgetType.EMBED]: embedWidgetConfigSchema,
|
||||
[WidgetType.STATUS]: statusWidgetConfigSchema,
|
||||
[WidgetType.CLOCK]: clockWeatherWidgetConfigSchema,
|
||||
[WidgetType.SYSTEM_STATS]: systemStatsWidgetConfigSchema,
|
||||
[WidgetType.RSS]: rssWidgetConfigSchema,
|
||||
[WidgetType.CALENDAR]: calendarWidgetConfigSchema,
|
||||
[WidgetType.MARKDOWN]: markdownWidgetConfigSchema,
|
||||
[WidgetType.METRIC]: metricWidgetConfigSchema,
|
||||
[WidgetType.LINK_GROUP]: linkGroupWidgetConfigSchema,
|
||||
[WidgetType.CAMERA]: cameraWidgetConfigSchema
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate widget config JSON string against the schema for its widget type.
|
||||
* Returns the validated config string, or throws if invalid.
|
||||
*/
|
||||
function validateWidgetConfig(type: string, configStr: string): string {
|
||||
const schema = widgetConfigSchemas[type];
|
||||
if (!schema) {
|
||||
// Unknown widget type — allow any config to avoid breaking extensibility
|
||||
return configStr;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(configStr);
|
||||
} catch {
|
||||
throw new Error('Widget config is not valid JSON');
|
||||
}
|
||||
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const messages = result.error.errors.map((e: { message: string }) => e.message).join(', ');
|
||||
throw new Error(`Invalid widget config: ${messages}`);
|
||||
}
|
||||
|
||||
return configStr;
|
||||
}
|
||||
|
||||
// --- Board ---
|
||||
|
||||
@@ -135,6 +203,14 @@ export async function updateBoard(id: string, input: UpdateBoardInput) {
|
||||
if (input.isDefault !== undefined) data.isDefault = input.isDefault;
|
||||
if (input.isGuestAccessible !== undefined) data.isGuestAccessible = input.isGuestAccessible;
|
||||
if (input.backgroundConfig !== undefined) data.backgroundConfig = input.backgroundConfig;
|
||||
if (input.themeHue !== undefined) data.themeHue = input.themeHue;
|
||||
if (input.themeSaturation !== undefined) data.themeSaturation = input.themeSaturation;
|
||||
if (input.backgroundType !== undefined) data.backgroundType = input.backgroundType;
|
||||
if (input.cardSize !== undefined) data.cardSize = input.cardSize;
|
||||
if (input.wallpaperUrl !== undefined) data.wallpaperUrl = input.wallpaperUrl;
|
||||
if (input.wallpaperBlur !== undefined) data.wallpaperBlur = input.wallpaperBlur;
|
||||
if (input.wallpaperOverlay !== undefined) data.wallpaperOverlay = input.wallpaperOverlay;
|
||||
if (input.customCss !== undefined) data.customCss = input.customCss;
|
||||
|
||||
return prisma.board.update({
|
||||
where: { id },
|
||||
@@ -195,6 +271,7 @@ export async function updateSection(id: string, input: UpdateSectionInput) {
|
||||
if (input.icon !== undefined) data.icon = input.icon;
|
||||
if (input.order !== undefined) data.order = input.order;
|
||||
if (input.isExpandedByDefault !== undefined) data.isExpandedByDefault = input.isExpandedByDefault;
|
||||
if (input.cardSize !== undefined) data.cardSize = input.cardSize;
|
||||
|
||||
return prisma.section.update({
|
||||
where: { id },
|
||||
@@ -231,24 +308,32 @@ export async function createWidget(input: CreateWidgetInput) {
|
||||
order = (maxWidget?.order ?? -1) + 1;
|
||||
}
|
||||
|
||||
const configStr = input.config ?? '{}';
|
||||
validateWidgetConfig(input.type, configStr);
|
||||
|
||||
return prisma.widget.create({
|
||||
data: {
|
||||
sectionId: input.sectionId,
|
||||
type: input.type,
|
||||
order,
|
||||
config: input.config ?? '{}',
|
||||
config: configStr,
|
||||
appId: input.appId ?? null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWidget(id: string, input: UpdateWidgetInput) {
|
||||
await findWidgetById(id);
|
||||
const existing = await findWidgetById(id);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (input.type !== undefined) data.type = input.type;
|
||||
if (input.order !== undefined) data.order = input.order;
|
||||
if (input.config !== undefined) data.config = input.config;
|
||||
if (input.config !== undefined) {
|
||||
// Validate config against the widget type (use new type if provided, else existing type)
|
||||
const effectiveType = input.type ?? existing.type;
|
||||
validateWidgetConfig(effectiveType, input.config);
|
||||
data.config = input.config;
|
||||
}
|
||||
if (input.appId !== undefined) data.appId = input.appId;
|
||||
|
||||
return prisma.widget.update({
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Calendar service — fetches and parses iCal (.ics) files.
|
||||
* Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies).
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_DAYS_AHEAD = 14;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: string; // raw ical text
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
readonly summary: string;
|
||||
readonly start: string;
|
||||
readonly end: string;
|
||||
readonly location: string | null;
|
||||
readonly calendarLabel: string;
|
||||
readonly calendarColor: string;
|
||||
}
|
||||
|
||||
export interface CalendarSource {
|
||||
readonly url: string;
|
||||
readonly color?: string;
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function getCached(key: string): string | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: string): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss).
|
||||
*/
|
||||
function parseIcalDate(dateStr: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// Remove TZID parameter prefix if present
|
||||
const clean = dateStr.replace(/^.*:/, '').trim();
|
||||
|
||||
// All-day event: YYYYMMDD
|
||||
if (/^\d{8}$/.test(clean)) {
|
||||
const year = parseInt(clean.substring(0, 4), 10);
|
||||
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
||||
const day = parseInt(clean.substring(6, 8), 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
// DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
|
||||
const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
||||
if (dtMatch) {
|
||||
const [, year, month, day, hour, minute, second, utc] = dtMatch;
|
||||
if (utc === 'Z') {
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second, 10)
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second, 10)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a property value from an iCal VEVENT block.
|
||||
* Handles folded lines (continuation lines starting with space/tab).
|
||||
*/
|
||||
function extractProperty(block: string, property: string): string {
|
||||
// Match property with optional parameters (e.g., DTSTART;TZID=...:value)
|
||||
const regex = new RegExp(`^${property}[;:](.*)$`, 'im');
|
||||
const match = block.match(regex);
|
||||
if (!match) return '';
|
||||
|
||||
const value = match[1];
|
||||
|
||||
// If the property had parameters (;PARAM=value:actualValue), extract just the value
|
||||
if (property === 'DTSTART' || property === 'DTEND') {
|
||||
// Keep the full string — parseIcalDate handles TZID prefix
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse VEVENT blocks from iCal text.
|
||||
*/
|
||||
function parseVEvents(
|
||||
icalText: string
|
||||
): Array<{ summary: string; start: string; end: string; location: string }> {
|
||||
const events: Array<{ summary: string; start: string; end: string; location: string }> = [];
|
||||
|
||||
// Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations)
|
||||
const unfolded = icalText.replace(/\r?\n[ \t]/g, '');
|
||||
|
||||
const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = eventRegex.exec(unfolded)) !== null) {
|
||||
const block = match[1];
|
||||
const summary = extractProperty(block, 'SUMMARY');
|
||||
const dtStart = extractProperty(block, 'DTSTART');
|
||||
const dtEnd = extractProperty(block, 'DTEND');
|
||||
const location = extractProperty(block, 'LOCATION');
|
||||
|
||||
const startDate = parseIcalDate(dtStart);
|
||||
if (!startDate) continue;
|
||||
|
||||
const endDate = parseIcalDate(dtEnd);
|
||||
|
||||
events.push({
|
||||
summary: summary || 'Untitled Event',
|
||||
start: startDate.toISOString(),
|
||||
end: endDate ? endDate.toISOString() : startDate.toISOString(),
|
||||
location: location || ''
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch iCal text from a URL.
|
||||
*/
|
||||
async function fetchIcalText(url: string): Promise<string> {
|
||||
const cached = getCached(url);
|
||||
if (cached) return cached;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'WebAppLauncher/1.0',
|
||||
Accept: 'text/calendar, application/ics'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Calendar source returned ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
setCache(url, text);
|
||||
return text;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Calendar request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse events from multiple iCal URLs, merged and sorted by start time.
|
||||
*/
|
||||
export async function fetchCalendarEvents(
|
||||
sources: readonly CalendarSource[],
|
||||
daysAhead?: number
|
||||
): Promise<readonly CalendarEvent[]> {
|
||||
const days = daysAhead ?? DEFAULT_DAYS_AHEAD;
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sources.map(async (source) => {
|
||||
const icalText = await fetchIcalText(source.url);
|
||||
const events = parseVEvents(icalText);
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
const start = new Date(event.start);
|
||||
return start >= now && start <= cutoff;
|
||||
})
|
||||
.map((event) => ({
|
||||
summary: event.summary,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
location: event.location || null,
|
||||
calendarLabel: source.label ?? 'Calendar',
|
||||
calendarColor: source.color ?? '#6366f1'
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allEvents.push(...result.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time ascending
|
||||
return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the calendar cache.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Camera/Stream proxy service — proxies image requests to camera URLs.
|
||||
* Includes SSRF protection to reject private IP ranges.
|
||||
*/
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const RATE_LIMIT_INTERVAL_MS = 5_000; // Max 1 request per 5s per URL
|
||||
|
||||
const lastFetchTimes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Check if a hostname resolves to a private/reserved IP range.
|
||||
* Prevents SSRF attacks by blocking requests to internal networks.
|
||||
*/
|
||||
function isPrivateOrReservedHost(hostname: string): boolean {
|
||||
// Block obvious private hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check IPv4 private ranges
|
||||
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number);
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 127.0.0.0/8
|
||||
if (a === 127) return true;
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
}
|
||||
|
||||
// Block IPv6 private ranges (simplified check)
|
||||
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL for camera proxying.
|
||||
* Only allows http/https and rejects private IPs.
|
||||
*/
|
||||
export function validateStreamUrl(urlStr: string): { valid: boolean; error?: string } {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlStr);
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid URL format' };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { valid: false, error: 'Only http and https protocols are allowed' };
|
||||
}
|
||||
|
||||
if (isPrivateOrReservedHost(parsed.hostname)) {
|
||||
return { valid: false, error: 'Requests to private/reserved IP ranges are not allowed' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for a given URL.
|
||||
*/
|
||||
function checkRateLimit(url: string): boolean {
|
||||
const lastFetch = lastFetchTimes.get(url);
|
||||
if (!lastFetch) return true;
|
||||
return Date.now() - lastFetch >= RATE_LIMIT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function recordFetch(url: string): void {
|
||||
lastFetchTimes.set(url, Date.now());
|
||||
}
|
||||
|
||||
export interface CameraSnapshot {
|
||||
readonly buffer: Buffer;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a snapshot image from a camera URL.
|
||||
* Proxies the HTTP request and returns the image buffer.
|
||||
*/
|
||||
export async function fetchSnapshot(url: string): Promise<CameraSnapshot> {
|
||||
// Validate URL
|
||||
const validation = validateStreamUrl(url);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error ?? 'Invalid stream URL');
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
if (!checkRateLimit(url)) {
|
||||
throw new Error('Rate limited: please wait before requesting this camera again');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
recordFetch(url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Camera returned ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
throw new Error('Camera returned empty response');
|
||||
}
|
||||
|
||||
return { buffer, contentType };
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Camera request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the rate limit tracking.
|
||||
*/
|
||||
export function clearRateLimits(): void {
|
||||
lastFetchTimes.clear();
|
||||
}
|
||||
@@ -123,8 +123,9 @@ export async function discoverDocker(socketPath: string): Promise<{
|
||||
continue; // Skip containers without accessible URLs
|
||||
}
|
||||
|
||||
const description = container.Labels['org.opencontainers.image.description']
|
||||
?? `Docker container: ${container.Image}`;
|
||||
const description =
|
||||
container.Labels['org.opencontainers.image.description'] ??
|
||||
`Docker container: ${container.Image}`;
|
||||
|
||||
services.push({
|
||||
name,
|
||||
@@ -187,9 +188,7 @@ export async function discoverTraefik(apiUrl: string): Promise<{
|
||||
const host = extractHostFromRule(router.rule);
|
||||
if (!host) continue;
|
||||
|
||||
const isSecure = router.entryPoints?.some(
|
||||
(ep) => ep === 'websecure' || ep === 'https'
|
||||
);
|
||||
const isSecure = router.entryPoints?.some((ep) => ep === 'websecure' || ep === 'https');
|
||||
const frontendUrl = `${isSecure ? 'https' : 'http'}://${host}`;
|
||||
|
||||
// Derive a clean name from the router name (strip @provider suffix)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
/**
|
||||
* Get user's favorite apps, ordered by position.
|
||||
*/
|
||||
export async function getUserFavorites(userId: string) {
|
||||
return prisma.userFavorite.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
app: {
|
||||
include: {
|
||||
statuses: {
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an app to user's favorites (append to end).
|
||||
*/
|
||||
export async function addFavorite(userId: string, appId: string) {
|
||||
// Check if already favorited
|
||||
const existing = await prisma.userFavorite.findUnique({
|
||||
where: { userId_appId: { userId, appId } }
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('App is already in favorites');
|
||||
}
|
||||
|
||||
// Get the next order value
|
||||
const maxFav = await prisma.userFavorite.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true }
|
||||
});
|
||||
const nextOrder = (maxFav?.order ?? -1) + 1;
|
||||
|
||||
return prisma.userFavorite.create({
|
||||
data: {
|
||||
userId,
|
||||
appId,
|
||||
order: nextOrder
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an app from user's favorites.
|
||||
*/
|
||||
export async function removeFavorite(userId: string, appId: string) {
|
||||
const existing = await prisma.userFavorite.findUnique({
|
||||
where: { userId_appId: { userId, appId } }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Error('App is not in favorites');
|
||||
}
|
||||
|
||||
await prisma.userFavorite.delete({
|
||||
where: { userId_appId: { userId, appId } }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder user's favorites by setting order based on array position.
|
||||
*/
|
||||
export async function reorderFavorites(userId: string, favoriteIds: string[]) {
|
||||
const updates = favoriteIds.map((id, index) =>
|
||||
prisma.userFavorite.update({
|
||||
where: { id },
|
||||
data: { order: index }
|
||||
})
|
||||
);
|
||||
|
||||
return prisma.$transaction(updates);
|
||||
}
|
||||
@@ -118,8 +118,6 @@ export async function getGroupMembers(groupId: string) {
|
||||
|
||||
export async function addUserToDefaultGroups(userId: string) {
|
||||
const defaultGroups = await findDefaultGroups();
|
||||
const results = await Promise.all(
|
||||
defaultGroups.map((group) => addUser(group.id, userId))
|
||||
);
|
||||
const results = await Promise.all(defaultGroups.map((group) => addUser(group.id, userId)));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface ImportResult {
|
||||
readonly settingsUpdated: boolean;
|
||||
}
|
||||
|
||||
export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } {
|
||||
export function validateImportData(
|
||||
data: unknown
|
||||
): { success: true; data: ExportData } | { success: false; errors: string[] } {
|
||||
const parsed = importDataSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
|
||||
@@ -206,10 +208,13 @@ export async function importData(data: ExportData, mode: ImportMode): Promise<Im
|
||||
const s = data.settings;
|
||||
|
||||
if (s.authMode !== undefined) settingsData.authMode = s.authMode;
|
||||
if (s.registrationEnabled !== undefined) settingsData.registrationEnabled = s.registrationEnabled;
|
||||
if (s.registrationEnabled !== undefined)
|
||||
settingsData.registrationEnabled = s.registrationEnabled;
|
||||
if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme;
|
||||
if (s.defaultPrimaryColor !== undefined) settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
|
||||
if (s.healthcheckDefaults !== undefined) settingsData.healthcheckDefaults = s.healthcheckDefaults;
|
||||
if (s.defaultPrimaryColor !== undefined)
|
||||
settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
|
||||
if (s.healthcheckDefaults !== undefined)
|
||||
settingsData.healthcheckDefaults = s.healthcheckDefaults;
|
||||
|
||||
if (Object.keys(settingsData).length > 0) {
|
||||
await tx.systemSettings.upsert({
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Metric/Counter service — fetches single numeric values from various sources.
|
||||
* Supports static values, JSON endpoints with dot-path extraction, and Prometheus queries.
|
||||
* Tracks previous values for trend calculation.
|
||||
*/
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 60_000; // 1 minute
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: MetricResult;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface MetricResult {
|
||||
readonly value: number;
|
||||
readonly previousValue: number | null;
|
||||
readonly trend: 'up' | 'down' | 'flat';
|
||||
readonly unit: string;
|
||||
readonly fetchedAt: string;
|
||||
}
|
||||
|
||||
export type MetricSource = 'static' | 'json' | 'prometheus';
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const previousValues = new Map<string, number>();
|
||||
|
||||
function getCached(key: string): MetricResult | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: MetricResult, ttlMs: number): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse an object using dot-notation path (e.g., "data.cpu.percent").
|
||||
*/
|
||||
function extractByPath(obj: unknown, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
|
||||
// Handle array indexing: "items.0.value"
|
||||
const index = parseInt(part, 10);
|
||||
if (Array.isArray(current) && !isNaN(index)) {
|
||||
current = current[index];
|
||||
} else {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trend based on current and previous values.
|
||||
*/
|
||||
function calculateTrend(current: number, previous: number | null): 'up' | 'down' | 'flat' {
|
||||
if (previous === null) return 'flat';
|
||||
if (current > previous) return 'up';
|
||||
if (current < previous) return 'down';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
|
||||
});
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Metric request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a metric value from a JSON HTTP endpoint, extracting via dot-path.
|
||||
*/
|
||||
export async function fetchHttpMetric(url: string, jsonPath: string): Promise<number> {
|
||||
const response = await fetchWithTimeout(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Metric endpoint returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const extracted = extractByPath(data, jsonPath);
|
||||
|
||||
if (typeof extracted === 'number') return extracted;
|
||||
if (typeof extracted === 'string') {
|
||||
const parsed = parseFloat(extracted);
|
||||
if (!isNaN(parsed)) return parsed;
|
||||
}
|
||||
|
||||
throw new Error(`Could not extract numeric value at path "${jsonPath}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a metric value from Prometheus instant query API.
|
||||
*/
|
||||
export async function fetchPrometheusMetric(url: string, query: string): Promise<number> {
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`;
|
||||
|
||||
const response = await fetchWithTimeout(endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Prometheus returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
if (data.status !== 'success') {
|
||||
throw new Error('Prometheus query failed');
|
||||
}
|
||||
|
||||
const resultData = data.data as Record<string, unknown>;
|
||||
const results = resultData?.result as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (results && results.length > 0) {
|
||||
const value = results[0].value as [number, string] | undefined;
|
||||
if (value && value.length === 2) {
|
||||
return parseFloat(value[1]) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No result from Prometheus query');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a static metric value (passthrough).
|
||||
*/
|
||||
export function getStaticMetric(value: string): number {
|
||||
const parsed = parseFloat(value);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error(`Invalid static metric value: "${value}"`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a metric from any supported source type.
|
||||
*/
|
||||
export async function fetchMetric(options: {
|
||||
readonly source: MetricSource;
|
||||
readonly value?: string;
|
||||
readonly url?: string;
|
||||
readonly jsonPath?: string;
|
||||
readonly query?: string;
|
||||
readonly unit?: string;
|
||||
readonly refreshInterval?: number;
|
||||
}): Promise<MetricResult> {
|
||||
const cacheKey = `${options.source}:${options.url ?? ''}:${options.jsonPath ?? ''}:${options.query ?? ''}:${options.value ?? ''}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let numericValue: number;
|
||||
|
||||
switch (options.source) {
|
||||
case 'static': {
|
||||
if (!options.value) throw new Error('Static metric requires a value');
|
||||
numericValue = getStaticMetric(options.value);
|
||||
break;
|
||||
}
|
||||
case 'json': {
|
||||
if (!options.url) throw new Error('JSON metric requires a url');
|
||||
if (!options.jsonPath) throw new Error('JSON metric requires a jsonPath');
|
||||
numericValue = await fetchHttpMetric(options.url, options.jsonPath);
|
||||
break;
|
||||
}
|
||||
case 'prometheus': {
|
||||
if (!options.url) throw new Error('Prometheus metric requires a url');
|
||||
if (!options.query) throw new Error('Prometheus metric requires a query');
|
||||
numericValue = await fetchPrometheusMetric(options.url, options.query);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown metric source: ${options.source}`);
|
||||
}
|
||||
|
||||
const prevValue = previousValues.get(cacheKey) ?? null;
|
||||
const trend = calculateTrend(numericValue, prevValue);
|
||||
previousValues.set(cacheKey, numericValue);
|
||||
|
||||
const result: MetricResult = {
|
||||
value: numericValue,
|
||||
previousValue: prevValue,
|
||||
trend,
|
||||
unit: options.unit ?? '',
|
||||
fetchedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const ttl = options.refreshInterval ? options.refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS;
|
||||
setCache(cacheKey, result, ttl);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the metric cache and previous values.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
previousValues.clear();
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
import { NotificationType } from '$lib/utils/constants.js';
|
||||
|
||||
// --- Channel Management ---
|
||||
|
||||
export async function createChannel(
|
||||
userId: string,
|
||||
type: string,
|
||||
config: string,
|
||||
enabled: boolean = true
|
||||
) {
|
||||
return prisma.notificationChannel.create({
|
||||
data: { userId, type, config, enabled }
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: { type?: string; config?: string; enabled?: boolean }
|
||||
) {
|
||||
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
|
||||
if (!channel || channel.userId !== userId) {
|
||||
throw new Error('Notification channel not found');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.type !== undefined) updateData.type = data.type;
|
||||
if (data.config !== undefined) updateData.config = data.config;
|
||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||
|
||||
return prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannel(id: string, userId: string) {
|
||||
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
|
||||
if (!channel || channel.userId !== userId) {
|
||||
throw new Error('Notification channel not found');
|
||||
}
|
||||
|
||||
await prisma.notificationChannel.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function listChannels(userId: string) {
|
||||
return prisma.notificationChannel.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelById(id: string, userId: string) {
|
||||
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
|
||||
if (!channel || channel.userId !== userId) {
|
||||
throw new Error('Notification channel not found');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
// --- Notification Dispatchers ---
|
||||
|
||||
interface DiscordConfig {
|
||||
readonly webhookUrl: string;
|
||||
}
|
||||
|
||||
interface SlackConfig {
|
||||
readonly webhookUrl: string;
|
||||
}
|
||||
|
||||
interface TelegramConfig {
|
||||
readonly botToken: string;
|
||||
readonly chatId: string;
|
||||
}
|
||||
|
||||
interface HttpConfig {
|
||||
readonly url: string;
|
||||
readonly headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function sendDiscord(webhookUrl: string, message: string): Promise<void> {
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: 'Web App Launcher Notification',
|
||||
description: message,
|
||||
color: 0x6366f1,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: swallow errors
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSlack(webhookUrl: string, message: string): Promise<void> {
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Web App Launcher*\n${message}`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: swallow errors
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTelegram(botToken: string, chatId: string, message: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
parse_mode: 'HTML'
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: swallow errors
|
||||
}
|
||||
}
|
||||
|
||||
async function sendHttp(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(headers ?? {})
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: swallow errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a message to a single notification channel.
|
||||
*/
|
||||
async function dispatchToChannel(
|
||||
channel: { type: string; config: string },
|
||||
message: string
|
||||
): Promise<void> {
|
||||
let config: unknown;
|
||||
try {
|
||||
config = JSON.parse(channel.config);
|
||||
} catch {
|
||||
return; // Invalid config — skip
|
||||
}
|
||||
|
||||
switch (channel.type) {
|
||||
case NotificationType.DISCORD: {
|
||||
const dc = config as DiscordConfig;
|
||||
if (dc.webhookUrl) {
|
||||
await sendDiscord(dc.webhookUrl, message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NotificationType.SLACK: {
|
||||
const sc = config as SlackConfig;
|
||||
if (sc.webhookUrl) {
|
||||
await sendSlack(sc.webhookUrl, message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NotificationType.TELEGRAM: {
|
||||
const tc = config as TelegramConfig;
|
||||
if (tc.botToken && tc.chatId) {
|
||||
await sendTelegram(tc.botToken, tc.chatId, message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NotificationType.HTTP: {
|
||||
const hc = config as HttpConfig;
|
||||
if (hc.url) {
|
||||
await sendHttp(hc.url, { message, timestamp: new Date().toISOString() }, hc.headers);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send Notification ---
|
||||
|
||||
/**
|
||||
* Send a notification to all enabled channels for a user.
|
||||
* Creates a Notification record and dispatches to all channels.
|
||||
*/
|
||||
export async function sendNotification(
|
||||
userId: string,
|
||||
appId: string | null,
|
||||
event: string,
|
||||
message: string
|
||||
) {
|
||||
// Create notification record
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
appId,
|
||||
event,
|
||||
message
|
||||
}
|
||||
});
|
||||
|
||||
// Get all enabled channels for the user and dispatch
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
where: { userId, enabled: true }
|
||||
});
|
||||
|
||||
// Fire-and-forget: dispatch to all channels in parallel
|
||||
Promise.allSettled(channels.map((ch) => dispatchToChannel(ch, message))).catch(() => {
|
||||
// Swallow any unexpected errors
|
||||
});
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to all users that have notification channels set up.
|
||||
* Used by the healthcheck scheduler for broadcast status change events.
|
||||
*/
|
||||
export async function broadcastNotification(appId: string, event: string, message: string) {
|
||||
// Find all users that have at least one enabled notification channel
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
where: { enabled: true },
|
||||
select: { userId: true }
|
||||
});
|
||||
|
||||
const uniqueUserIds = [...new Set(channels.map((ch) => ch.userId))];
|
||||
|
||||
// Create notifications and dispatch for each user
|
||||
await Promise.allSettled(
|
||||
uniqueUserIds.map((userId) => sendNotification(userId, appId, event, message))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test notification to a specific channel.
|
||||
*/
|
||||
export async function sendTestNotification(channelId: string, userId: string): Promise<void> {
|
||||
const channel = await getChannelById(channelId, userId);
|
||||
const message = 'This is a test notification from Web App Launcher.';
|
||||
await dispatchToChannel(channel, message);
|
||||
}
|
||||
|
||||
// --- Notification Queries ---
|
||||
|
||||
export async function getNotifications(
|
||||
userId: string,
|
||||
options?: { unreadOnly?: boolean; limit?: number; offset?: number }
|
||||
) {
|
||||
const where: Record<string, unknown> = { userId };
|
||||
if (options?.unreadOnly) {
|
||||
where.readAt = null;
|
||||
}
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: options?.limit ?? 50,
|
||||
skip: options?.offset ?? 0,
|
||||
include: {
|
||||
app: {
|
||||
select: { id: true, name: true, icon: true }
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.notification.count({ where })
|
||||
]);
|
||||
|
||||
return { notifications, total };
|
||||
}
|
||||
|
||||
export async function markAsRead(notificationId: string, userId: string) {
|
||||
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!notification || notification.userId !== userId) {
|
||||
throw new Error('Notification not found');
|
||||
}
|
||||
|
||||
return prisma.notification.update({
|
||||
where: { id: notificationId },
|
||||
data: { readAt: new Date() }
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAllAsRead(userId: string) {
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId, readAt: null },
|
||||
data: { readAt: new Date() }
|
||||
});
|
||||
}
|
||||
@@ -68,11 +68,7 @@ async function getOIDCConfig(): Promise<client.Configuration> {
|
||||
}
|
||||
|
||||
const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl);
|
||||
const config = await client.discovery(
|
||||
issuerUrl,
|
||||
oauthConfig.clientId,
|
||||
oauthConfig.clientSecret
|
||||
);
|
||||
const config = await client.discovery(issuerUrl, oauthConfig.clientId, oauthConfig.clientSecret);
|
||||
|
||||
cachedConfig = config;
|
||||
cachedConfigKey = cacheKey;
|
||||
@@ -157,7 +153,9 @@ export async function handleCallback(
|
||||
|
||||
const email = (userInfo.email as string) || '';
|
||||
if (!email) {
|
||||
throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.');
|
||||
throw new Error(
|
||||
'OAuth provider did not return an email address. Ensure the "email" scope is configured.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
|
||||
/**
|
||||
* Check whether the onboarding wizard should be shown.
|
||||
* Returns true if no users exist OR SystemSettings.onboardingComplete is false.
|
||||
*/
|
||||
export async function isOnboardingNeeded(): Promise<boolean> {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
select: { onboardingComplete: true }
|
||||
});
|
||||
|
||||
// If no settings record exists yet, onboarding is needed
|
||||
if (!settings) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !settings.onboardingComplete;
|
||||
} catch {
|
||||
// If SystemSettings table doesn't exist yet, onboarding is needed
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete in SystemSettings.
|
||||
*/
|
||||
export async function completeOnboarding(): Promise<void> {
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
update: { onboardingComplete: true },
|
||||
create: {
|
||||
id: DEFAULTS.SYSTEM_SETTINGS_ID,
|
||||
onboardingComplete: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current onboarding status.
|
||||
*/
|
||||
export async function getOnboardingStatus(): Promise<{
|
||||
readonly needed: boolean;
|
||||
readonly hasUsers: boolean;
|
||||
readonly onboardingComplete: boolean;
|
||||
}> {
|
||||
const userCount = await prisma.user.count();
|
||||
const hasUsers = userCount > 0;
|
||||
|
||||
let onboardingComplete = false;
|
||||
try {
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||
select: { onboardingComplete: true }
|
||||
});
|
||||
onboardingComplete = settings?.onboardingComplete ?? false;
|
||||
} catch {
|
||||
onboardingComplete = false;
|
||||
}
|
||||
|
||||
return {
|
||||
needed: !hasUsers || !onboardingComplete,
|
||||
hasUsers,
|
||||
onboardingComplete
|
||||
};
|
||||
}
|
||||
@@ -74,8 +74,7 @@ export async function checkPermission(
|
||||
return permLevel > highestScore ? perm.level : highest;
|
||||
}, groupPermissions[0].level);
|
||||
|
||||
const hasAccess =
|
||||
PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
|
||||
const hasAccess = PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
|
||||
return {
|
||||
hasPermission: hasAccess,
|
||||
effectiveLevel: highestLevel as PermissionCheckResult['effectiveLevel'],
|
||||
@@ -137,20 +136,14 @@ export async function getPermissionsForEntity(entityType: EntityType, entityId:
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPermissionsForTarget(
|
||||
targetType: TargetTypeType,
|
||||
targetId: string
|
||||
) {
|
||||
export async function getPermissionsForTarget(targetType: TargetTypeType, targetId: string) {
|
||||
return prisma.permission.findMany({
|
||||
where: { targetType, targetId },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeAllPermissionsForEntity(
|
||||
entityType: EntityType,
|
||||
entityId: string
|
||||
) {
|
||||
export async function removeAllPermissionsForEntity(entityType: EntityType, entityId: string) {
|
||||
await prisma.permission.deleteMany({
|
||||
where: { entityType, entityId }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
/**
|
||||
* Record a click on an app for a user.
|
||||
*/
|
||||
export async function recordClick(userId: string, appId: string) {
|
||||
return prisma.appClick.create({
|
||||
data: {
|
||||
userId,
|
||||
appId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent unique apps for a user, most recent first.
|
||||
*/
|
||||
export async function getRecentApps(userId: string, limit: number = 10) {
|
||||
// Get distinct most-recent clicks per app
|
||||
const clicks = await prisma.appClick.findMany({
|
||||
where: { userId },
|
||||
orderBy: { clickedAt: 'desc' },
|
||||
include: {
|
||||
app: {
|
||||
include: {
|
||||
statuses: {
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate by appId, keeping the most recent click per app
|
||||
const seen = new Set<string>();
|
||||
const uniqueClicks = [];
|
||||
for (const click of clicks) {
|
||||
if (!seen.has(click.appId)) {
|
||||
seen.add(click.appId);
|
||||
uniqueClicks.push(click);
|
||||
}
|
||||
if (uniqueClicks.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueClicks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all click history for a user.
|
||||
*/
|
||||
export async function clearHistory(userId: string) {
|
||||
await prisma.appClick.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* RSS/Atom feed service — fetches and parses RSS/Atom feeds.
|
||||
* Uses lightweight XML parsing without heavy dependencies.
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_ITEMS = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: readonly FeedItem[];
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
readonly title: string;
|
||||
readonly link: string;
|
||||
readonly pubDate: string;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function getCached(key: string): readonly FeedItem[] | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: readonly FeedItem[]): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content between XML tags.
|
||||
*/
|
||||
function extractTag(xml: string, tag: string): string {
|
||||
// Handle CDATA sections
|
||||
const cdataPattern = new RegExp(
|
||||
`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`,
|
||||
'i'
|
||||
);
|
||||
const cdataMatch = xml.match(cdataPattern);
|
||||
if (cdataMatch) return cdataMatch[1].trim();
|
||||
|
||||
// Handle regular content
|
||||
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
|
||||
const match = xml.match(pattern);
|
||||
if (match) return match[1].trim();
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract href from Atom link tag.
|
||||
*/
|
||||
function extractAtomLink(entryXml: string): string {
|
||||
// Look for link with rel="alternate" or no rel
|
||||
const altMatch = entryXml.match(/<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i);
|
||||
if (altMatch) return altMatch[1];
|
||||
|
||||
const hrefMatch = entryXml.match(/<link[^>]*href=["']([^"']+)["']/i);
|
||||
if (hrefMatch) return hrefMatch[1];
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RSS 2.0 feed XML.
|
||||
*/
|
||||
function parseRss(xml: string, maxItems: number): readonly FeedItem[] {
|
||||
const items: FeedItem[] = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null && items.length < maxItems) {
|
||||
const itemXml = match[1];
|
||||
items.push({
|
||||
title: extractTag(itemXml, 'title') || 'Untitled',
|
||||
link: extractTag(itemXml, 'link') || '',
|
||||
pubDate: extractTag(itemXml, 'pubDate') || '',
|
||||
summary: extractTag(itemXml, 'description') || ''
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Atom feed XML.
|
||||
*/
|
||||
function parseAtom(xml: string, maxItems: number): readonly FeedItem[] {
|
||||
const items: FeedItem[] = [];
|
||||
const entryRegex = /<entry>([\s\S]*?)<\/entry>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = entryRegex.exec(xml)) !== null && items.length < maxItems) {
|
||||
const entryXml = match[1];
|
||||
items.push({
|
||||
title: extractTag(entryXml, 'title') || 'Untitled',
|
||||
link: extractAtomLink(entryXml) || '',
|
||||
pubDate: extractTag(entryXml, 'published') || extractTag(entryXml, 'updated') || '',
|
||||
summary: extractTag(entryXml, 'summary') || extractTag(entryXml, 'content') || ''
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from a string (for summaries).
|
||||
*/
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse an RSS or Atom feed from a URL.
|
||||
*/
|
||||
export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<readonly FeedItem[]> {
|
||||
const limit = maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const cacheKey = `${feedUrl}:${limit}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'WebAppLauncher/1.0',
|
||||
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feed returned ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
// Detect feed type and parse
|
||||
let items: readonly FeedItem[];
|
||||
if (xml.includes('<feed') && xml.includes('xmlns="http://www.w3.org/2005/Atom"')) {
|
||||
items = parseAtom(xml, limit);
|
||||
} else {
|
||||
items = parseRss(xml, limit);
|
||||
}
|
||||
|
||||
// Strip HTML from summaries
|
||||
const cleanItems = items.map((item) => ({
|
||||
...item,
|
||||
summary: stripHtml(item.summary).substring(0, 500)
|
||||
}));
|
||||
|
||||
setCache(cacheKey, cleanItems);
|
||||
return cleanItems;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Feed request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the RSS feed cache.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* System stats service — fetches metrics from various sources using an adapter pattern.
|
||||
* Supports Glances, Prometheus, and custom JSON endpoints.
|
||||
*/
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 30_000; // 30 seconds
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: readonly SystemMetric[];
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface SystemMetric {
|
||||
readonly metric: string;
|
||||
readonly value: number;
|
||||
readonly unit: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function getCached(key: string): readonly SystemMetric[] | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: readonly SystemMetric[], ttlMs: number): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Source returned ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('System stats request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glances adapter — fetches from Glances REST API.
|
||||
* Expects endpoints like /api/3/cpu, /api/3/mem, /api/3/fs
|
||||
*/
|
||||
async function fetchGlancesMetrics(
|
||||
sourceUrl: string,
|
||||
metrics: readonly string[]
|
||||
): Promise<readonly SystemMetric[]> {
|
||||
const results: SystemMetric[] = [];
|
||||
|
||||
for (const metric of metrics) {
|
||||
try {
|
||||
const endpoint = `${sourceUrl.replace(/\/$/, '')}/api/3/${metric}`;
|
||||
const data = await fetchWithTimeout(endpoint);
|
||||
|
||||
if (metric === 'cpu' && typeof data === 'object' && data !== null) {
|
||||
const cpuData = data as Record<string, unknown>;
|
||||
const total = typeof cpuData.total === 'number' ? cpuData.total : 0;
|
||||
results.push({ metric: 'cpu', value: total, unit: '%' });
|
||||
} else if (metric === 'mem' && typeof data === 'object' && data !== null) {
|
||||
const memData = data as Record<string, unknown>;
|
||||
const percent = typeof memData.percent === 'number' ? memData.percent : 0;
|
||||
results.push({ metric: 'memory', value: percent, unit: '%' });
|
||||
} else if (metric === 'fs' && Array.isArray(data)) {
|
||||
for (const disk of data) {
|
||||
const d = disk as Record<string, unknown>;
|
||||
const percent = typeof d.percent === 'number' ? d.percent : 0;
|
||||
const mnt = typeof d.mnt_point === 'string' ? d.mnt_point : '/';
|
||||
results.push({ metric: `disk:${mnt}`, value: percent, unit: '%' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreachable metric endpoints
|
||||
results.push({ metric, value: -1, unit: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prometheus adapter — queries Prometheus instant query API.
|
||||
*/
|
||||
async function fetchPrometheusMetrics(
|
||||
sourceUrl: string,
|
||||
metrics: readonly string[]
|
||||
): Promise<readonly SystemMetric[]> {
|
||||
const results: SystemMetric[] = [];
|
||||
const baseUrl = sourceUrl.replace(/\/$/, '');
|
||||
|
||||
for (const query of metrics) {
|
||||
try {
|
||||
const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`;
|
||||
const data = (await fetchWithTimeout(endpoint)) as Record<string, unknown>;
|
||||
|
||||
if (data.status === 'success') {
|
||||
const result = data.data as Record<string, unknown>;
|
||||
const resultArray = result?.result as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (resultArray && resultArray.length > 0) {
|
||||
const value = resultArray[0].value as [number, string] | undefined;
|
||||
if (value && value.length === 2) {
|
||||
results.push({
|
||||
metric: query,
|
||||
value: parseFloat(value[1]) || 0,
|
||||
unit: ''
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ metric: query, value: 0, unit: '' });
|
||||
} catch {
|
||||
results.push({ metric: query, value: -1, unit: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom adapter — fetches from a generic JSON endpoint.
|
||||
* Expects the response to be an object with metric names as keys and numeric values.
|
||||
*/
|
||||
async function fetchCustomMetrics(
|
||||
sourceUrl: string,
|
||||
metrics: readonly string[]
|
||||
): Promise<readonly SystemMetric[]> {
|
||||
const results: SystemMetric[] = [];
|
||||
|
||||
try {
|
||||
const data = (await fetchWithTimeout(sourceUrl)) as Record<string, unknown>;
|
||||
|
||||
for (const metric of metrics) {
|
||||
const value = data[metric];
|
||||
if (typeof value === 'number') {
|
||||
results.push({ metric, value, unit: '' });
|
||||
} else {
|
||||
results.push({ metric, value: 0, unit: '' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const metric of metrics) {
|
||||
results.push({ metric, value: -1, unit: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export type SourceType = 'glances' | 'prometheus' | 'custom';
|
||||
|
||||
/**
|
||||
* Fetch system stats from the specified source.
|
||||
*/
|
||||
export async function fetchSystemStats(
|
||||
sourceUrl: string,
|
||||
sourceType: SourceType,
|
||||
metrics: readonly string[],
|
||||
refreshInterval?: number
|
||||
): Promise<readonly SystemMetric[]> {
|
||||
const cacheKey = `${sourceType}:${sourceUrl}:${metrics.join(',')}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let result: readonly SystemMetric[];
|
||||
|
||||
switch (sourceType) {
|
||||
case 'glances':
|
||||
result = await fetchGlancesMetrics(sourceUrl, metrics);
|
||||
break;
|
||||
case 'prometheus':
|
||||
result = await fetchPrometheusMetrics(sourceUrl, metrics);
|
||||
break;
|
||||
case 'custom':
|
||||
result = await fetchCustomMetrics(sourceUrl, metrics);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown source type: ${sourceType}`);
|
||||
}
|
||||
|
||||
const ttl = refreshInterval ? refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS;
|
||||
setCache(cacheKey, result, ttl);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the system stats cache.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
// --- Tag CRUD ---
|
||||
|
||||
export async function findAll() {
|
||||
return prisma.tag.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: { select: { appTags: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function findById(id: string) {
|
||||
const tag = await prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { appTags: true } }
|
||||
}
|
||||
});
|
||||
if (!tag) {
|
||||
throw new Error(`Tag not found: ${id}`);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
export async function create(name: string, color?: string | null) {
|
||||
const existing = await prisma.tag.findUnique({ where: { name } });
|
||||
if (existing) {
|
||||
throw new Error(`Tag already exists: ${name}`);
|
||||
}
|
||||
|
||||
return prisma.tag.create({
|
||||
data: {
|
||||
name,
|
||||
color: color ?? null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function update(id: string, data: { name?: string; color?: string | null }) {
|
||||
await findById(id);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.color !== undefined) updateData.color = data.color;
|
||||
|
||||
return prisma.tag.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(id: string) {
|
||||
await findById(id);
|
||||
await prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// --- App-Tag Associations ---
|
||||
|
||||
export async function addTagToApp(appId: string, tagId: string) {
|
||||
const existing = await prisma.appTag.findUnique({
|
||||
where: { appId_tagId: { appId, tagId } }
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('Tag is already assigned to this app');
|
||||
}
|
||||
|
||||
return prisma.appTag.create({
|
||||
data: { appId, tagId },
|
||||
include: { tag: true }
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTagFromApp(appId: string, tagId: string) {
|
||||
const existing = await prisma.appTag.findUnique({
|
||||
where: { appId_tagId: { appId, tagId } }
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Error('Tag is not assigned to this app');
|
||||
}
|
||||
|
||||
await prisma.appTag.delete({
|
||||
where: { appId_tagId: { appId, tagId } }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTagsForApp(appId: string) {
|
||||
const appTags = await prisma.appTag.findMany({
|
||||
where: { appId },
|
||||
include: { tag: true },
|
||||
orderBy: { tag: { name: 'asc' } }
|
||||
});
|
||||
return appTags.map((at) => at.tag);
|
||||
}
|
||||
|
||||
export async function getAppsByTag(tagId: string) {
|
||||
const appTags = await prisma.appTag.findMany({
|
||||
where: { tagId },
|
||||
include: {
|
||||
app: {
|
||||
include: {
|
||||
statuses: {
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return appTags.map((at) => at.app);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
export interface TemplateSection {
|
||||
readonly title: string;
|
||||
readonly icon?: string | null;
|
||||
readonly order?: number;
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly sections: readonly TemplateSection[];
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly icon: string | null;
|
||||
readonly config: TemplateConfig;
|
||||
readonly isBuiltin: boolean;
|
||||
readonly createdById: string | null;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in templates that are always available, not stored in DB.
|
||||
*/
|
||||
const BUILTIN_TEMPLATES: readonly Template[] = [
|
||||
{
|
||||
id: 'builtin-home-server',
|
||||
name: 'Home Server',
|
||||
description:
|
||||
'Typical home server dashboard with media, networking, storage, and monitoring sections',
|
||||
icon: 'server',
|
||||
config: {
|
||||
sections: [
|
||||
{ title: 'Media', icon: 'play-circle', order: 0 },
|
||||
{ title: 'Networking', icon: 'network', order: 1 },
|
||||
{ title: 'Storage', icon: 'hard-drive', order: 2 },
|
||||
{ title: 'Monitoring', icon: 'activity', order: 3 }
|
||||
]
|
||||
},
|
||||
isBuiltin: true,
|
||||
createdById: null,
|
||||
createdAt: new Date('2024-01-01')
|
||||
},
|
||||
{
|
||||
id: 'builtin-media-stack',
|
||||
name: 'Media Stack',
|
||||
description: 'Media management with streaming, downloads, and library management sections',
|
||||
icon: 'film',
|
||||
config: {
|
||||
sections: [
|
||||
{ title: 'Streaming', icon: 'tv', order: 0 },
|
||||
{ title: 'Downloads', icon: 'download', order: 1 },
|
||||
{ title: 'Management', icon: 'folder', order: 2 }
|
||||
]
|
||||
},
|
||||
isBuiltin: true,
|
||||
createdById: null,
|
||||
createdAt: new Date('2024-01-01')
|
||||
},
|
||||
{
|
||||
id: 'builtin-dev-tools',
|
||||
name: 'Dev Tools',
|
||||
description: 'Developer-focused layout with Git, CI/CD, databases, and documentation sections',
|
||||
icon: 'code',
|
||||
config: {
|
||||
sections: [
|
||||
{ title: 'Git', icon: 'git-branch', order: 0 },
|
||||
{ title: 'CI/CD', icon: 'rocket', order: 1 },
|
||||
{ title: 'Databases', icon: 'database', order: 2 },
|
||||
{ title: 'Docs', icon: 'book-open', order: 3 }
|
||||
]
|
||||
},
|
||||
isBuiltin: true,
|
||||
createdById: null,
|
||||
createdAt: new Date('2024-01-01')
|
||||
},
|
||||
{
|
||||
id: 'builtin-monitoring',
|
||||
name: 'Monitoring',
|
||||
description: 'Infrastructure monitoring with metrics, logs, alerts, and status sections',
|
||||
icon: 'activity',
|
||||
config: {
|
||||
sections: [
|
||||
{ title: 'Metrics', icon: 'bar-chart-2', order: 0 },
|
||||
{ title: 'Logs', icon: 'file-text', order: 1 },
|
||||
{ title: 'Alerts', icon: 'bell', order: 2 },
|
||||
{ title: 'Status', icon: 'check-circle', order: 3 }
|
||||
]
|
||||
},
|
||||
isBuiltin: true,
|
||||
createdById: null,
|
||||
createdAt: new Date('2024-01-01')
|
||||
}
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get all built-in templates.
|
||||
*/
|
||||
export function getBuiltinTemplates(): readonly Template[] {
|
||||
return BUILTIN_TEMPLATES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-created templates from DB.
|
||||
*/
|
||||
export async function getUserTemplates(userId?: string): Promise<Template[]> {
|
||||
const where = userId ? { createdById: userId, isBuiltin: false } : { isBuiltin: false };
|
||||
|
||||
const dbTemplates = await prisma.boardTemplate.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
return dbTemplates.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
icon: t.icon,
|
||||
config: parseConfig(t.config),
|
||||
isBuiltin: t.isBuiltin,
|
||||
createdById: t.createdById,
|
||||
createdAt: t.createdAt
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all templates (builtin + user-created).
|
||||
*/
|
||||
export async function getAllTemplates(userId?: string): Promise<Template[]> {
|
||||
const userTemplates = await getUserTemplates(userId);
|
||||
return [...BUILTIN_TEMPLATES, ...userTemplates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single template by ID (checks builtins first, then DB).
|
||||
*/
|
||||
export async function getTemplateById(id: string): Promise<Template | null> {
|
||||
const builtin = BUILTIN_TEMPLATES.find((t) => t.id === id);
|
||||
if (builtin) return builtin;
|
||||
|
||||
const dbTemplate = await prisma.boardTemplate.findUnique({ where: { id } });
|
||||
if (!dbTemplate) return null;
|
||||
|
||||
return {
|
||||
id: dbTemplate.id,
|
||||
name: dbTemplate.name,
|
||||
description: dbTemplate.description,
|
||||
icon: dbTemplate.icon,
|
||||
config: parseConfig(dbTemplate.config),
|
||||
isBuiltin: dbTemplate.isBuiltin,
|
||||
createdById: dbTemplate.createdById,
|
||||
createdAt: dbTemplate.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user template from a config.
|
||||
*/
|
||||
export async function createTemplate(input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
config: TemplateConfig;
|
||||
createdById?: string | null;
|
||||
}): Promise<Template> {
|
||||
const dbTemplate = await prisma.boardTemplate.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
icon: input.icon ?? null,
|
||||
config: JSON.stringify(input.config),
|
||||
isBuiltin: false,
|
||||
createdById: input.createdById ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: dbTemplate.id,
|
||||
name: dbTemplate.name,
|
||||
description: dbTemplate.description,
|
||||
icon: dbTemplate.icon,
|
||||
config: parseConfig(dbTemplate.config),
|
||||
isBuiltin: dbTemplate.isBuiltin,
|
||||
createdById: dbTemplate.createdById,
|
||||
createdAt: dbTemplate.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user-created template. Cannot delete builtins.
|
||||
*/
|
||||
export async function deleteTemplate(id: string): Promise<void> {
|
||||
const builtin = BUILTIN_TEMPLATES.find((t) => t.id === id);
|
||||
if (builtin) {
|
||||
throw new Error('Cannot delete built-in templates');
|
||||
}
|
||||
|
||||
const existing = await prisma.boardTemplate.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error(`Template not found: ${id}`);
|
||||
}
|
||||
|
||||
await prisma.boardTemplate.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a template to a board: create sections from the template config.
|
||||
*/
|
||||
export async function applyTemplate(templateId: string, boardId: string): Promise<void> {
|
||||
const template = await getTemplateById(templateId);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${templateId}`);
|
||||
}
|
||||
|
||||
// Create sections from template config
|
||||
const createOps = template.config.sections.map((section) =>
|
||||
prisma.section.create({
|
||||
data: {
|
||||
boardId,
|
||||
title: section.title,
|
||||
icon: section.icon,
|
||||
order: section.order,
|
||||
isExpandedByDefault: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$transaction(createOps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a board's layout as a template config JSON.
|
||||
*/
|
||||
export async function exportTemplate(boardId: string): Promise<{
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly config: TemplateConfig;
|
||||
}> {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: boardId },
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
select: { title: true, icon: true, order: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
throw new Error(`Board not found: ${boardId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: board.name,
|
||||
description: board.description,
|
||||
config: {
|
||||
sections: board.sections.map((s) => ({
|
||||
title: s.title,
|
||||
icon: s.icon,
|
||||
order: s.order
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a template from JSON data.
|
||||
*/
|
||||
export async function importTemplate(
|
||||
data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
config: TemplateConfig;
|
||||
},
|
||||
createdById?: string | null
|
||||
): Promise<Template> {
|
||||
// Validate config structure
|
||||
if (!data.config?.sections || !Array.isArray(data.config.sections)) {
|
||||
throw new Error('Invalid template config: sections array is required');
|
||||
}
|
||||
|
||||
for (const section of data.config.sections) {
|
||||
if (!section.title || typeof section.title !== 'string') {
|
||||
throw new Error('Invalid template config: each section must have a title');
|
||||
}
|
||||
}
|
||||
|
||||
return createTemplate({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
icon: data.icon ?? null,
|
||||
config: {
|
||||
sections: data.config.sections.map((s, i) => ({
|
||||
title: s.title,
|
||||
icon: s.icon ?? null,
|
||||
order: s.order ?? i
|
||||
}))
|
||||
},
|
||||
createdById: createdById ?? null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON config string into TemplateConfig.
|
||||
*/
|
||||
function parseConfig(configStr: string): TemplateConfig {
|
||||
try {
|
||||
const parsed = JSON.parse(configStr);
|
||||
if (parsed?.sections && Array.isArray(parsed.sections)) {
|
||||
return parsed as TemplateConfig;
|
||||
}
|
||||
return { sections: [] };
|
||||
} catch {
|
||||
return { sections: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
import { AppStatusValue } from '$lib/utils/constants.js';
|
||||
|
||||
type TimeRange = '24h' | '7d' | '30d';
|
||||
|
||||
function getTimeRangeCutoff(timeRange: TimeRange): Date {
|
||||
const now = Date.now();
|
||||
const hours = timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
|
||||
return new Date(now - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uptime statistics for a single app.
|
||||
*/
|
||||
export async function getUptimeStats(appId: string, timeRange: TimeRange = '24h') {
|
||||
const cutoff = getTimeRangeCutoff(timeRange);
|
||||
|
||||
const statuses = await prisma.appStatus.findMany({
|
||||
where: {
|
||||
appId,
|
||||
checkedAt: { gte: cutoff }
|
||||
},
|
||||
orderBy: { checkedAt: 'asc' }
|
||||
});
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return {
|
||||
appId,
|
||||
timeRange,
|
||||
currentStatus: null as string | null,
|
||||
uptimePercentage: null,
|
||||
avgResponseTime: null,
|
||||
totalChecks: 0,
|
||||
onlineChecks: 0,
|
||||
offlineChecks: 0,
|
||||
degradedChecks: 0
|
||||
};
|
||||
}
|
||||
|
||||
const onlineChecks = statuses.filter((s) => s.status === AppStatusValue.ONLINE).length;
|
||||
const offlineChecks = statuses.filter((s) => s.status === AppStatusValue.OFFLINE).length;
|
||||
const degradedChecks = statuses.filter((s) => s.status === AppStatusValue.DEGRADED).length;
|
||||
const relevantChecks = onlineChecks + offlineChecks + degradedChecks;
|
||||
|
||||
const uptimePercentage =
|
||||
relevantChecks > 0 ? Math.round((onlineChecks / relevantChecks) * 10000) / 100 : null;
|
||||
|
||||
const responseTimes = statuses
|
||||
.map((s) => s.responseTime)
|
||||
.filter((rt): rt is number => rt !== null);
|
||||
const avgResponseTime =
|
||||
responseTimes.length > 0
|
||||
? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length)
|
||||
: null;
|
||||
|
||||
// Most recent status check determines current status
|
||||
const currentStatus = statuses[statuses.length - 1].status;
|
||||
|
||||
return {
|
||||
appId,
|
||||
timeRange,
|
||||
currentStatus,
|
||||
uptimePercentage,
|
||||
avgResponseTime,
|
||||
totalChecks: statuses.length,
|
||||
onlineChecks,
|
||||
offlineChecks,
|
||||
degradedChecks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uptime timeline for a single app (status history with timestamps).
|
||||
*/
|
||||
export async function getUptimeTimeline(appId: string, timeRange: TimeRange = '24h') {
|
||||
const cutoff = getTimeRangeCutoff(timeRange);
|
||||
|
||||
const statuses = await prisma.appStatus.findMany({
|
||||
where: {
|
||||
appId,
|
||||
checkedAt: { gte: cutoff }
|
||||
},
|
||||
orderBy: { checkedAt: 'asc' },
|
||||
select: {
|
||||
status: true,
|
||||
responseTime: true,
|
||||
checkedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated uptime for all apps with healthcheck enabled.
|
||||
*/
|
||||
export async function getAllAppsUptime(timeRange: TimeRange = '24h') {
|
||||
const apps = await prisma.app.findMany({
|
||||
where: { healthcheckEnabled: true },
|
||||
select: { id: true, name: true, url: true }
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
const stats = await getUptimeStats(app.id, timeRange);
|
||||
return {
|
||||
...stats,
|
||||
appName: app.name,
|
||||
appUrl: app.url
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incidents (down periods) for an app or all apps.
|
||||
*/
|
||||
export async function getIncidents(appId?: string, timeRange: TimeRange = '24h') {
|
||||
const cutoff = getTimeRangeCutoff(timeRange);
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
checkedAt: { gte: cutoff },
|
||||
status: { in: [AppStatusValue.OFFLINE, AppStatusValue.DEGRADED] }
|
||||
};
|
||||
if (appId) {
|
||||
where.appId = appId;
|
||||
}
|
||||
|
||||
const statuses = await prisma.appStatus.findMany({
|
||||
where,
|
||||
orderBy: { checkedAt: 'asc' },
|
||||
include: {
|
||||
app: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Group consecutive offline/degraded statuses into incidents
|
||||
const incidents: Array<{
|
||||
readonly appId: string;
|
||||
readonly appName: string;
|
||||
readonly status: string;
|
||||
readonly startedAt: Date;
|
||||
readonly endedAt: Date;
|
||||
readonly durationMs: number;
|
||||
readonly checkCount: number;
|
||||
}> = [];
|
||||
|
||||
let currentIncident: {
|
||||
appId: string;
|
||||
appName: string;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
checkCount: number;
|
||||
} | null = null;
|
||||
|
||||
for (const status of statuses) {
|
||||
if (
|
||||
currentIncident &&
|
||||
currentIncident.appId === status.appId &&
|
||||
currentIncident.status === status.status
|
||||
) {
|
||||
// Continue current incident
|
||||
const prev: {
|
||||
appId: string;
|
||||
appName: string;
|
||||
status: string;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
checkCount: number;
|
||||
} = currentIncident;
|
||||
currentIncident = {
|
||||
appId: prev.appId,
|
||||
appName: prev.appName,
|
||||
status: prev.status,
|
||||
startedAt: prev.startedAt,
|
||||
endedAt: status.checkedAt,
|
||||
checkCount: prev.checkCount + 1
|
||||
};
|
||||
} else {
|
||||
// Save previous incident
|
||||
if (currentIncident) {
|
||||
incidents.push({
|
||||
...currentIncident,
|
||||
durationMs: currentIncident.endedAt.getTime() - currentIncident.startedAt.getTime()
|
||||
});
|
||||
}
|
||||
// Start new incident
|
||||
currentIncident = {
|
||||
appId: status.appId,
|
||||
appName: status.app.name,
|
||||
status: status.status,
|
||||
startedAt: status.checkedAt,
|
||||
endedAt: status.checkedAt,
|
||||
checkCount: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last incident
|
||||
if (currentIncident) {
|
||||
incidents.push({
|
||||
...currentIncident,
|
||||
durationMs: currentIncident.endedAt.getTime() - currentIncident.startedAt.getTime()
|
||||
});
|
||||
}
|
||||
|
||||
return incidents;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Weather service — fetches current weather from OpenMeteo API.
|
||||
* No API key required.
|
||||
*/
|
||||
|
||||
const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1/forecast';
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CacheEntry {
|
||||
readonly data: WeatherData;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
readonly temperature: number;
|
||||
readonly windSpeed: number;
|
||||
readonly weatherCode: number;
|
||||
readonly isDay: boolean;
|
||||
readonly time: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function buildCacheKey(latitude: number, longitude: number): string {
|
||||
// Round to 2 decimal places for cache key stability
|
||||
return `${latitude.toFixed(2)},${longitude.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function getCached(key: string): WeatherData | null {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(key: string, data: WeatherData): void {
|
||||
cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current weather for given coordinates.
|
||||
*/
|
||||
export async function fetchWeather(latitude: number, longitude: number): Promise<WeatherData> {
|
||||
const cacheKey = buildCacheKey(latitude, longitude);
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `${OPEN_METEO_BASE}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenMeteo API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const current = json?.current_weather;
|
||||
|
||||
if (!current || typeof current.temperature !== 'number') {
|
||||
throw new Error('Unexpected response format from OpenMeteo API');
|
||||
}
|
||||
|
||||
const data: WeatherData = {
|
||||
temperature: current.temperature,
|
||||
windSpeed: current.windspeed ?? 0,
|
||||
weatherCode: current.weathercode ?? 0,
|
||||
isDay: current.is_day === 1,
|
||||
time: current.time ?? new Date().toISOString()
|
||||
};
|
||||
|
||||
setCache(cacheKey, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Weather API request timed out');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the weather cache (useful for testing or manual refresh).
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -26,12 +26,7 @@ export function error(message: string): ApiResponse<null> {
|
||||
};
|
||||
}
|
||||
|
||||
export function paginated<T>(
|
||||
data: T,
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): ApiResponse<T> {
|
||||
export function paginated<T>(data: T, total: number, page: number, limit: number): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
|
||||
Reference in New Issue
Block a user