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:
2026-03-25 14:18:10 +03:00
parent 8d7847889e
commit 1c0a7cb850
212 changed files with 15642 additions and 981 deletions
+72 -1
View File
@@ -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;
}
}
+24
View File
@@ -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' }]);
+127
View File
@@ -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;
}
+88 -6
View File
@@ -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;
}
+6 -5
View File
@@ -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);
+89 -4
View File
@@ -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({
+238
View File
@@ -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();
}
+149
View File
@@ -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();
}
+4 -5
View File
@@ -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);
}
+1 -3
View File
@@ -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;
}
+9 -4
View File
@@ -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({
+227
View File
@@ -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() }
});
}
+4 -6
View File
@@ -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
};
}
+3 -10
View File
@@ -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 }
});
}
+189
View File
@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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();
}
+112
View File
@@ -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);
}
+319
View File
@@ -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: [] };
}
}
+213
View File
@@ -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;
}
+102
View File
@@ -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}&current_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();
}
+1 -6
View File
@@ -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,