diff --git a/src/lib/server/integrations/authentik/client.ts b/src/lib/server/integrations/authentik/client.ts new file mode 100644 index 0000000..2a8daf4 --- /dev/null +++ b/src/lib/server/integrations/authentik/client.ts @@ -0,0 +1,70 @@ +import { fetchWithTimeout } from '../base.js'; + +export interface AuthentikEvent { + readonly pk: string; + readonly action: string; + readonly user: { readonly username: string; readonly pk: number }; + readonly client_ip: string; + readonly created: string; + readonly context: Record; +} + +export interface AuthentikPaginatedResponse { + readonly pagination: { readonly count: number; readonly total_pages: number }; + readonly results: readonly T[]; +} + +function buildHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/json' + }; +} + +function normalizeUrl(appUrl: string): string { + return appUrl.replace(/\/+$/, ''); +} + +export async function fetchUserCount(appUrl: string, token: string): Promise { + const url = `${normalizeUrl(appUrl)}/api/v3/core/users/?page_size=1`; + const res = await fetchWithTimeout(url, { headers: buildHeaders(token) }); + if (!res.ok) { + throw new Error(`Authentik API returned ${res.status}`); + } + const json: AuthentikPaginatedResponse = await res.json(); + return json.pagination.count; +} + +export async function fetchLoginEvents( + appUrl: string, + token: string, + limit = 20 +): Promise { + const url = `${normalizeUrl(appUrl)}/api/v3/events/events/?action=login&ordering=-created&page_size=${limit}`; + const res = await fetchWithTimeout(url, { headers: buildHeaders(token) }); + if (!res.ok) { + throw new Error(`Authentik API returned ${res.status}`); + } + const json: AuthentikPaginatedResponse = await res.json(); + return json.results; +} + +export async function fetchFailedLogins( + appUrl: string, + token: string, + limit = 50 +): Promise { + const url = `${normalizeUrl(appUrl)}/api/v3/events/events/?action=login_failed&ordering=-created&page_size=${limit}`; + const res = await fetchWithTimeout(url, { headers: buildHeaders(token) }); + if (!res.ok) { + throw new Error(`Authentik API returned ${res.status}`); + } + const json: AuthentikPaginatedResponse = await res.json(); + return json.results; +} + +export async function testAuth(appUrl: string, token: string): Promise { + const url = `${normalizeUrl(appUrl)}/api/v3/core/tokens/?page_size=1`; + const res = await fetchWithTimeout(url, { headers: buildHeaders(token) }); + return res.ok; +} diff --git a/src/lib/server/integrations/authentik/index.ts b/src/lib/server/integrations/authentik/index.ts new file mode 100644 index 0000000..38e5af1 --- /dev/null +++ b/src/lib/server/integrations/authentik/index.ts @@ -0,0 +1,106 @@ +import type { Integration, IntegrationData, IntegrationEndpoint } from '../types.js'; +import { wrapError } from '../base.js'; +import { authentikAuthConfigSchema, authentikExtraConfigSchema } from './schema.js'; +import { fetchUserCount, fetchLoginEvents, fetchFailedLogins, testAuth } from './client.js'; +import { toSessionStats, toLoginEvents, toSecurityAlerts } from './transform.js'; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'user-stats', + name: 'User Stats', + description: 'Total user count', + renderer: 'stat-card', + refreshInterval: 300 + }, + { + id: 'login-events', + name: 'Login Events', + description: 'Recent login activity', + renderer: 'list', + refreshInterval: 60 + }, + { + id: 'security-alerts', + name: 'Security Alerts', + description: 'Brute force detection', + renderer: 'alert-banner', + refreshInterval: 30 + } +] as const; + +async function testConnection( + appUrl: string, + config: Record +): Promise<{ readonly success: boolean; readonly message: string }> { + try { + const { apiToken } = authentikAuthConfigSchema.parse(config); + const ok = await testAuth(appUrl, apiToken); + return ok + ? { success: true, message: 'Connected to Authentik' } + : { success: false, message: 'Authentication failed — check API token' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, message: `Failed to connect to Authentik: ${message}` }; + } +} + +async function fetchData( + appUrl: string, + config: Record, + endpointId: string +): Promise { + const { apiToken } = authentikAuthConfigSchema.parse(config); + + try { + switch (endpointId) { + case 'user-stats': { + const count = await fetchUserCount(appUrl, apiToken); + return { + endpointId, + renderer: 'stat-card', + data: toSessionStats(count), + fetchedAt: new Date().toISOString() + }; + } + case 'login-events': { + const events = await fetchLoginEvents(appUrl, apiToken); + return { + endpointId, + renderer: 'list', + data: toLoginEvents(events), + fetchedAt: new Date().toISOString() + }; + } + case 'security-alerts': { + const failedEvents = await fetchFailedLogins(appUrl, apiToken); + const extraConfig = authentikExtraConfigSchema.parse(config); + return { + endpointId, + renderer: 'alert-banner', + data: toSecurityAlerts( + failedEvents, + extraConfig.failedLoginThreshold, + extraConfig.failedLoginWindowMinutes + ), + fetchedAt: new Date().toISOString() + }; + } + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + } catch (error) { + throw wrapError('authentik', endpointId, error); + } +} + +export const authentikIntegration: Integration = { + id: 'authentik', + name: 'Authentik', + icon: 'authentik', + description: 'Identity provider — user stats, login events, and brute-force detection', + authConfigSchema: authentikAuthConfigSchema, + extraConfigSchema: authentikExtraConfigSchema, + endpoints, + testConnection, + fetchData +}; diff --git a/src/lib/server/integrations/authentik/schema.ts b/src/lib/server/integrations/authentik/schema.ts new file mode 100644 index 0000000..44ed6cf --- /dev/null +++ b/src/lib/server/integrations/authentik/schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const authentikAuthConfigSchema = z.object({ + apiToken: z.string().min(1, 'API token is required') +}); + +export const authentikExtraConfigSchema = z.object({ + failedLoginThreshold: z.number().int().min(1).default(5), + failedLoginWindowMinutes: z.number().int().min(1).default(10) +}); + +export type AuthentikAuthConfig = z.infer; +export type AuthentikExtraConfig = z.infer; diff --git a/src/lib/server/integrations/authentik/transform.ts b/src/lib/server/integrations/authentik/transform.ts new file mode 100644 index 0000000..b39526c --- /dev/null +++ b/src/lib/server/integrations/authentik/transform.ts @@ -0,0 +1,82 @@ +import type { StatCardData, ListData, AlertBannerData } from '../types.js'; +import type { AuthentikEvent } from './client.js'; + +function formatRelativeTime(isoDate: string): string { + const now = Date.now(); + const then = new Date(isoDate).getTime(); + const diffMs = now - then; + + if (diffMs < 0) return 'just now'; + + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function toSessionStats(userCount: number): StatCardData { + return { + label: 'Total Users', + value: userCount + }; +} + +export function toLoginEvents(events: readonly AuthentikEvent[]): ListData { + return { + items: events.map((event) => { + const isFailed = event.action === 'login_failed'; + return { + id: event.pk, + title: event.user.username, + subtitle: `${event.client_ip} \u00b7 ${formatRelativeTime(event.created)}`, + badge: isFailed + ? { text: 'Failed', color: 'red' } + : { text: 'Success', color: 'green' } + }; + }) + }; +} + +export function toSecurityAlerts( + failedEvents: readonly AuthentikEvent[], + threshold: number, + windowMinutes: number +): AlertBannerData { + const windowMs = windowMinutes * 60 * 1000; + const cutoff = Date.now() - windowMs; + + const recentFailures = failedEvents.filter( + (event) => new Date(event.created).getTime() >= cutoff + ); + + const failuresByIp = new Map(); + for (const event of recentFailures) { + const count = failuresByIp.get(event.client_ip) ?? 0; + failuresByIp.set(event.client_ip, count + 1); + } + + const offendingIps: readonly string[] = Array.from(failuresByIp.entries()) + .filter(([, count]) => count >= threshold) + .map(([ip, count]) => `${ip} (${count} failures)`); + + if (offendingIps.length > 0) { + return { + severity: 'critical', + title: 'Brute Force Detected', + message: `${offendingIps.length} IP(s) exceeded ${threshold} failed logins in ${windowMinutes}min: ${offendingIps.join(', ')}` + }; + } + + return { + severity: 'info', + title: 'Security Status', + message: 'No brute force detected' + }; +} diff --git a/src/lib/server/integrations/gitea/client.ts b/src/lib/server/integrations/gitea/client.ts new file mode 100644 index 0000000..0b1ba38 --- /dev/null +++ b/src/lib/server/integrations/gitea/client.ts @@ -0,0 +1,116 @@ +import { fetchWithTimeout } from '../base.js'; + +export interface GiteaUser { + readonly login: string; + readonly full_name: string; +} + +export interface GiteaRepo { + readonly full_name: string; + readonly name: string; + readonly owner: { readonly login: string }; + readonly html_url: string; + readonly updated_at: string; +} + +export interface GiteaCommit { + readonly sha: string; + readonly commit: { + readonly message: string; + readonly author: { readonly name: string; readonly date: string }; + }; + readonly html_url: string; +} + +export interface GiteaPullRequest { + readonly number: number; + readonly title: string; + readonly user: { readonly login: string }; + readonly html_url: string; + readonly created_at: string; +} + +export interface GiteaRelease { + readonly tag_name: string; + readonly name: string; + readonly published_at: string; + readonly html_url: string; +} + +function buildBaseUrl(appUrl: string): string { + return `${appUrl.replace(/\/$/, '')}/api/v1`; +} + +async function authedRequest(appUrl: string, token: string, path: string): Promise { + const url = `${buildBaseUrl(appUrl)}${path}`; + const res = await fetchWithTimeout(url, { + headers: { Authorization: `token ${token}` } + }); + if (!res.ok) { + throw new Error(`Gitea API returned ${res.status}: ${res.statusText}`); + } + return res.json(); +} + +export async function fetchUser(appUrl: string, token: string): Promise { + return authedRequest(appUrl, token, '/user'); +} + +export async function fetchRepos( + appUrl: string, + token: string, + repoFilter?: readonly string[] +): Promise { + const repos = await authedRequest( + appUrl, + token, + '/repos/search?limit=20' + ); + + if (!repoFilter || repoFilter.length === 0) { + return repos; + } + + const filterSet = new Set(repoFilter); + return repos.filter((r) => filterSet.has(r.full_name) || filterSet.has(r.name)); +} + +export async function fetchCommits( + appUrl: string, + token: string, + owner: string, + repo: string, + limit = 10 +): Promise { + return authedRequest( + appUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?limit=${limit}` + ); +} + +export async function fetchPullRequests( + appUrl: string, + token: string, + owner: string, + repo: string +): Promise { + return authedRequest( + appUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open&limit=20` + ); +} + +export async function fetchReleases( + appUrl: string, + token: string, + owner: string, + repo: string +): Promise { + return authedRequest( + appUrl, + token, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases?limit=5` + ); +} diff --git a/src/lib/server/integrations/gitea/index.ts b/src/lib/server/integrations/gitea/index.ts new file mode 100644 index 0000000..f5d790b --- /dev/null +++ b/src/lib/server/integrations/gitea/index.ts @@ -0,0 +1,186 @@ +import type { Integration, IntegrationData, IntegrationEndpoint } from '../types.js'; +import { giteaAuthConfigSchema, giteaExtraConfigSchema } from './schema.js'; +import type { GiteaExtraConfig } from './schema.js'; +import { fetchUser, fetchRepos, fetchCommits, fetchPullRequests, fetchReleases } from './client.js'; +import { toRecentCommits, toOpenPrs, toReleases } from './transform.js'; +import { wrapError } from '../base.js'; + +const MAX_REPOS = 5; +const MAX_ITEMS_PER_REPO = 10; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'recent-commits', + name: 'Recent Commits', + description: 'Latest commits across repos', + renderer: 'list', + refreshInterval: 120 + }, + { + id: 'open-prs', + name: 'Open PRs', + description: 'Open pull request count', + renderer: 'stat-card', + refreshInterval: 120 + }, + { + id: 'releases', + name: 'Releases', + description: 'Latest releases across repos', + renderer: 'list', + refreshInterval: 600 + } +]; + +async function getFilteredRepos( + appUrl: string, + token: string, + extra?: GiteaExtraConfig +) { + const repos = await fetchRepos(appUrl, token, extra?.repos); + return repos.slice(0, MAX_REPOS); +} + +export const giteaIntegration: Integration = { + id: 'gitea', + name: 'Gitea', + icon: 'git-branch', + description: 'Monitor repositories, commits, pull requests, and releases from Gitea', + authConfigSchema: giteaAuthConfigSchema, + extraConfigSchema: giteaExtraConfigSchema, + endpoints, + + async testConnection(appUrl: string, config: Record) { + try { + const { apiToken } = giteaAuthConfigSchema.parse(config); + const user = await fetchUser(appUrl, apiToken); + return { + success: true, + message: `Connected as ${user.full_name || user.login}` + }; + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : 'Connection failed' + }; + } + }, + + async fetchData( + appUrl: string, + config: Record, + endpointId: string + ): Promise { + try { + const { apiToken } = giteaAuthConfigSchema.parse(config); + const extra = giteaExtraConfigSchema.safeParse(config); + const extraConfig = extra.success ? extra.data : undefined; + const repos = await getFilteredRepos(appUrl, apiToken, extraConfig); + + switch (endpointId) { + case 'recent-commits': { + const allCommits = await Promise.all( + repos.map(async (r) => { + const commits = await fetchCommits( + appUrl, + apiToken, + r.owner.login, + r.name, + MAX_ITEMS_PER_REPO + ); + return commits.map((c) => ({ ...c, _repo: r.full_name })); + }) + ); + const flat = allCommits + .flat() + .sort( + (a, b) => + new Date(b.commit.author.date).getTime() - + new Date(a.commit.author.date).getTime() + ) + .slice(0, MAX_ITEMS_PER_REPO); + + const grouped = flat.map((c) => ({ + commit: c, + repo: c._repo + })); + + const items = grouped.flatMap((g) => + toRecentCommits([g.commit], g.repo).items + ); + + return { + endpointId, + renderer: 'list' as const, + data: { items }, + fetchedAt: new Date().toISOString() + }; + } + + case 'open-prs': { + const allPrs = await Promise.all( + repos.map(async (r) => ({ + repo: r.full_name, + prs: await fetchPullRequests(appUrl, apiToken, r.owner.login, r.name) + })) + ); + return { + endpointId, + renderer: 'stat-card' as const, + data: toOpenPrs(allPrs), + fetchedAt: new Date().toISOString() + }; + } + + case 'releases': { + const allReleases = await Promise.all( + repos.map(async (r) => ({ + repo: r.full_name, + releases: await fetchReleases(appUrl, apiToken, r.owner.login, r.name) + })) + ); + const data = toReleases(allReleases); + const sorted = [...data.items].sort((a, b) => { + const aTime = a.badge ? parseRelativeBack(a.badge.text) : 0; + const bTime = b.badge ? parseRelativeBack(b.badge.text) : 0; + return bTime - aTime; + }); + return { + endpointId, + renderer: 'list' as const, + data: { items: sorted.slice(0, MAX_ITEMS_PER_REPO) }, + fetchedAt: new Date().toISOString() + }; + } + + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + } catch (err) { + throw wrapError('gitea', endpointId, err); + } + } +}; + +/** + * Rough reverse-parse of relative time strings for sorting. + * Returns a higher number for more recent items. + */ +function parseRelativeBack(relative: string): number { + const now = Date.now(); + const match = relative.match(/^(\d+)(m|h|d|mo)\s+ago$/); + if (!match) return now; + const amount = Number(match[1]); + switch (match[2]) { + case 'm': + return now - amount * 60_000; + case 'h': + return now - amount * 3_600_000; + case 'd': + return now - amount * 86_400_000; + case 'mo': + return now - amount * 2_592_000_000; + default: + return now; + } +} diff --git a/src/lib/server/integrations/gitea/schema.ts b/src/lib/server/integrations/gitea/schema.ts new file mode 100644 index 0000000..4916db1 --- /dev/null +++ b/src/lib/server/integrations/gitea/schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const giteaAuthConfigSchema = z.object({ + apiToken: z.string().min(1, 'API token is required') +}); + +export const giteaExtraConfigSchema = z.object({ + repos: z.array(z.string()).optional() +}); + +export type GiteaAuthConfig = z.infer; +export type GiteaExtraConfig = z.infer; diff --git a/src/lib/server/integrations/gitea/transform.ts b/src/lib/server/integrations/gitea/transform.ts new file mode 100644 index 0000000..771c8db --- /dev/null +++ b/src/lib/server/integrations/gitea/transform.ts @@ -0,0 +1,91 @@ +import type { StatCardData, ListData } from '../types.js'; +import type { GiteaCommit, GiteaPullRequest, GiteaRelease } from './client.js'; + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffSeconds = Math.floor((now - then) / 1000); + + if (diffSeconds < 60) return 'just now'; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + const diffMonths = Math.floor(diffDays / 30); + return `${diffMonths}mo ago`; +} + +export function toRecentCommits( + commits: readonly GiteaCommit[], + repoName: string +): ListData { + return { + items: commits.map((c) => { + const shortSha = c.sha.slice(0, 7); + const message = c.commit.message.split('\n')[0]; + const author = c.commit.author.name; + const date = formatRelativeTime(c.commit.author.date); + + return { + id: c.sha, + title: message, + subtitle: `${author} \u00b7 ${date}`, + icon: shortSha, + url: c.html_url + }; + }) + }; +} + +export function toOpenPrs( + allPrs: readonly { readonly repo: string; readonly prs: readonly GiteaPullRequest[] }[] +): StatCardData { + const total = allPrs.reduce((sum, entry) => sum + entry.prs.length, 0); + const perRepo = allPrs + .filter((entry) => entry.prs.length > 0) + .map((entry) => `${entry.repo}: ${entry.prs.length}`) + .join(' | '); + + return { + label: 'Open Pull Requests', + value: total, + subtitle: perRepo || 'No open PRs' + }; +} + +export function toPrList( + allPrs: readonly { readonly repo: string; readonly prs: readonly GiteaPullRequest[] }[] +): ListData { + const items = allPrs.flatMap((entry) => + entry.prs.map((pr) => ({ + id: `${entry.repo}#${pr.number}`, + title: pr.title, + subtitle: `${entry.repo} #${pr.number} by ${pr.user.login}`, + badge: { text: entry.repo, color: 'blue' } as const, + url: pr.html_url + })) + ); + + return { items }; +} + +export function toReleases( + releases: readonly { + readonly repo: string; + readonly releases: readonly GiteaRelease[]; + }[] +): ListData { + const items = releases.flatMap((entry) => + entry.releases.map((r) => ({ + id: `${entry.repo}-${r.tag_name}`, + title: r.tag_name, + subtitle: r.name || entry.repo, + badge: { text: formatRelativeTime(r.published_at), color: 'gray' } as const, + url: r.html_url + })) + ); + + return { items }; +} diff --git a/src/lib/server/integrations/npm/client.ts b/src/lib/server/integrations/npm/client.ts new file mode 100644 index 0000000..1707748 --- /dev/null +++ b/src/lib/server/integrations/npm/client.ts @@ -0,0 +1,117 @@ +import { fetchWithTimeout } from '../base.js'; + +export interface NpmProxyHost { + readonly id: number; + readonly domain_names: readonly string[]; + readonly forward_host: string; + readonly forward_port: number; + readonly enabled: number; + readonly ssl_forced: number; + readonly certificate_id: number; + readonly meta: Record; +} + +export interface NpmCertificate { + readonly id: number; + readonly nice_name: string; + readonly domain_names: readonly string[]; + readonly expires_on: string; + readonly provider: string; + readonly meta: Record; +} + +const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; + +const tokenCache = new Map(); + +function buildBaseUrl(appUrl: string): string { + return `${appUrl.replace(/\/$/, '')}/api`; +} + +export async function getToken( + appUrl: string, + email: string, + password: string +): Promise { + const baseUrl = buildBaseUrl(appUrl); + const cached = tokenCache.get(baseUrl); + + if (cached && cached.expiresAt > Date.now()) { + return cached.token; + } + + const res = await fetchWithTimeout(`${baseUrl}/tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: email, secret: password }) + }); + + if (!res.ok) { + tokenCache.delete(baseUrl); + throw new Error(`NPM login failed with status ${res.status}`); + } + + const data = (await res.json()) as { token: string }; + const entry = { token: data.token, expiresAt: Date.now() + TOKEN_TTL_MS }; + tokenCache.set(baseUrl, entry); + return entry.token; +} + +function clearToken(appUrl: string): void { + const baseUrl = buildBaseUrl(appUrl); + tokenCache.delete(baseUrl); +} + +function authHeaders(token: string): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; +} + +async function authenticatedFetch( + appUrl: string, + path: string, + token: string, + email: string, + password: string +): Promise { + const baseUrl = buildBaseUrl(appUrl); + const url = `${baseUrl}${path}`; + + const res = await fetchWithTimeout(url, { headers: authHeaders(token) }); + + if (res.status === 401) { + clearToken(appUrl); + const freshToken = await getToken(appUrl, email, password); + return fetchWithTimeout(url, { headers: authHeaders(freshToken) }); + } + + return res; +} + +export async function fetchProxyHosts( + appUrl: string, + token: string, + email: string, + password: string +): Promise { + const res = await authenticatedFetch(appUrl, '/nginx/proxy-hosts', token, email, password); + if (!res.ok) { + throw new Error(`NPM API returned ${res.status} fetching proxy hosts`); + } + return res.json(); +} + +export async function fetchCertificates( + appUrl: string, + token: string, + email: string, + password: string +): Promise { + const res = await authenticatedFetch(appUrl, '/nginx/certificates', token, email, password); + if (!res.ok) { + throw new Error(`NPM API returned ${res.status} fetching certificates`); + } + return res.json(); +} diff --git a/src/lib/server/integrations/npm/index.ts b/src/lib/server/integrations/npm/index.ts new file mode 100644 index 0000000..37cc902 --- /dev/null +++ b/src/lib/server/integrations/npm/index.ts @@ -0,0 +1,105 @@ +import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js'; +import { wrapError } from '../base.js'; +import { npmAuthConfigSchema } from './schema.js'; +import { getToken, fetchProxyHosts, fetchCertificates } from './client.js'; +import { toProxyHostList, toSslCertificates, toUpstreamStatus } from './transform.js'; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'proxy-hosts', + name: 'Proxy Hosts', + description: 'Nginx proxy host list', + renderer: 'list', + refreshInterval: 300 + }, + { + id: 'ssl-certificates', + name: 'SSL Certificates', + description: 'Certificate expiry status', + renderer: 'list', + refreshInterval: 3600 + }, + { + id: 'upstream-status', + name: 'Upstream Status', + description: 'Proxy host counts', + renderer: 'stat-card', + refreshInterval: 300 + } +] as const; + +async function testConnection( + appUrl: string, + config: Record +): Promise { + try { + const { email, password } = npmAuthConfigSchema.parse(config); + const token = await getToken(appUrl, email, password); + const hosts = await fetchProxyHosts(appUrl, token, email, password); + + return { + success: true, + message: `Connected — ${hosts.length} proxy host${hosts.length === 1 ? '' : 's'} found` + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, message: `Failed to connect to Nginx Proxy Manager: ${message}` }; + } +} + +async function fetchData( + appUrl: string, + config: Record, + endpointId: string +): Promise { + const { email, password } = npmAuthConfigSchema.parse(config); + + try { + const token = await getToken(appUrl, email, password); + + switch (endpointId) { + case 'proxy-hosts': { + const hosts = await fetchProxyHosts(appUrl, token, email, password); + return { + endpointId, + renderer: 'list', + data: toProxyHostList(hosts), + fetchedAt: new Date().toISOString() + }; + } + case 'ssl-certificates': { + const certs = await fetchCertificates(appUrl, token, email, password); + return { + endpointId, + renderer: 'list', + data: toSslCertificates(certs), + fetchedAt: new Date().toISOString() + }; + } + case 'upstream-status': { + const hosts = await fetchProxyHosts(appUrl, token, email, password); + return { + endpointId, + renderer: 'stat-card', + data: toUpstreamStatus(hosts), + fetchedAt: new Date().toISOString() + }; + } + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + } catch (error) { + throw wrapError('npm', endpointId, error); + } +} + +export const npmIntegration: Integration = { + id: 'npm', + name: 'Nginx Proxy Manager', + icon: 'nginx-proxy-manager', + description: 'Reverse proxy host management and SSL certificate status', + authConfigSchema: npmAuthConfigSchema, + endpoints, + testConnection, + fetchData +}; diff --git a/src/lib/server/integrations/npm/schema.ts b/src/lib/server/integrations/npm/schema.ts new file mode 100644 index 0000000..fed7750 --- /dev/null +++ b/src/lib/server/integrations/npm/schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const npmAuthConfigSchema = z.object({ + email: z.string().email('Valid email required'), + password: z.string().min(1, 'Password is required') +}); + +export type NpmAuthConfig = z.infer; diff --git a/src/lib/server/integrations/npm/transform.ts b/src/lib/server/integrations/npm/transform.ts new file mode 100644 index 0000000..b4c15b3 --- /dev/null +++ b/src/lib/server/integrations/npm/transform.ts @@ -0,0 +1,58 @@ +import type { StatCardData, ListData } from '../types.js'; +import type { NpmProxyHost, NpmCertificate } from './client.js'; + +function enabledBadge(enabled: number): { readonly text: string; readonly color: string } { + return enabled === 1 + ? { text: 'enabled', color: 'green' } + : { text: 'disabled', color: 'red' }; +} + +function expiryBadge(expiresOn: string): { readonly text: string; readonly color: string } { + const days = Math.ceil( + (new Date(expiresOn).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + if (days < 0) { + return { text: 'expired', color: 'red' }; + } + if (days < 7) { + return { text: `${days}d left`, color: 'red' }; + } + if (days < 14) { + return { text: `${days}d left`, color: 'yellow' }; + } + return { text: `${days}d left`, color: 'green' }; +} + +export function toProxyHostList(hosts: readonly NpmProxyHost[]): ListData { + return { + items: hosts.map((h) => ({ + id: String(h.id), + title: h.domain_names.join(', '), + subtitle: `${h.forward_host}:${h.forward_port}`, + badge: enabledBadge(h.enabled) + })) + }; +} + +export function toSslCertificates(certs: readonly NpmCertificate[]): ListData { + return { + items: certs.map((c) => ({ + id: String(c.id), + title: c.nice_name, + subtitle: c.domain_names.join(', '), + badge: expiryBadge(c.expires_on) + })) + }; +} + +export function toUpstreamStatus(hosts: readonly NpmProxyHost[]): StatCardData { + const enabled = hosts.filter((h) => h.enabled === 1).length; + const disabled = hosts.length - enabled; + + return { + label: 'Proxy Hosts', + value: enabled, + subtitle: `${enabled} enabled, ${disabled} disabled` + }; +} diff --git a/src/lib/server/integrations/nut/client.ts b/src/lib/server/integrations/nut/client.ts new file mode 100644 index 0000000..83daed5 --- /dev/null +++ b/src/lib/server/integrations/nut/client.ts @@ -0,0 +1,186 @@ +import { createConnection } from 'net'; + +const CONNECT_TIMEOUT = 5000; +const READ_TIMEOUT = 5000; + +export interface NutVariable { + readonly name: string; + readonly value: string; +} + +function sendCommand( + host: string, + port: number, + command: string, + collectUntil: (lines: readonly string[]) => boolean +): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port }); + const lines: string[] = []; + let buffer = ''; + let connectTimer: ReturnType | null = null; + let readTimer: ReturnType | null = null; + let settled = false; + + function cleanup() { + if (connectTimer) clearTimeout(connectTimer); + if (readTimer) clearTimeout(readTimer); + socket.removeAllListeners(); + socket.destroy(); + } + + function settle(err: Error | null, result?: readonly string[]) { + if (settled) return; + settled = true; + cleanup(); + if (err) { + reject(err); + } else { + resolve(result ?? []); + } + } + + connectTimer = setTimeout(() => { + settle(new Error(`Connection to ${host}:${port} timed out after ${CONNECT_TIMEOUT}ms`)); + }, CONNECT_TIMEOUT); + + socket.on('connect', () => { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + readTimer = setTimeout(() => { + settle(new Error(`Read timed out after ${READ_TIMEOUT}ms`)); + }, READ_TIMEOUT); + + socket.write(command + '\n'); + }); + + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf-8'); + const parts = buffer.split('\n'); + // Keep the last partial line in the buffer + buffer = parts.pop() ?? ''; + + for (const line of parts) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + lines.push(trimmed); + } + } + + if (collectUntil(lines)) { + settle(null, lines); + } + }); + + socket.on('error', (err: Error) => { + settle(new Error(`NUT connection error: ${err.message}`)); + }); + + socket.on('close', () => { + // If we haven't settled yet, resolve with what we have + settle(null, lines); + }); + }); +} + +function parseVarLine(line: string): NutVariable | null { + // Format: VAR "" + const match = line.match(/^VAR\s+\S+\s+(\S+)\s+"(.*)"/); + if (!match) return null; + return { name: match[1], value: match[2] }; +} + +export async function getVariable( + host: string, + port: number, + upsName: string, + varName: string +): Promise { + const lines = await sendCommand( + host, + port, + `GET VAR ${upsName} ${varName}`, + (collected) => collected.length >= 1 + ); + + if (lines.length === 0) { + throw new Error(`No response for variable ${varName}`); + } + + const first = lines[0]; + if (first.startsWith('ERR')) { + throw new Error(`NUT error: ${first}`); + } + + const parsed = parseVarLine(first); + if (!parsed) { + throw new Error(`Unexpected response format: ${first}`); + } + + return parsed.value; +} + +export async function listVariables( + host: string, + port: number, + upsName: string +): Promise { + const endMarker = `END LIST VAR ${upsName}`; + + const lines = await sendCommand( + host, + port, + `LIST VAR ${upsName}`, + (collected) => collected.some((l) => l.startsWith('END LIST VAR')) + ); + + if (lines.length > 0 && lines[0].startsWith('ERR')) { + throw new Error(`NUT error: ${lines[0]}`); + } + + const variables: NutVariable[] = []; + for (const line of lines) { + if (line === endMarker || line.startsWith('BEGIN LIST VAR')) { + continue; + } + const parsed = parseVarLine(line); + if (parsed) { + variables.push(parsed); + } + } + + return variables; +} + +export async function listUps( + host: string, + port: number +): Promise { + const lines = await sendCommand( + host, + port, + 'LIST UPS', + (collected) => collected.some((l) => l.startsWith('END LIST UPS')) + ); + + if (lines.length > 0 && lines[0].startsWith('ERR')) { + throw new Error(`NUT error: ${lines[0]}`); + } + + const names: string[] = []; + for (const line of lines) { + if (line.startsWith('END LIST UPS') || line.startsWith('BEGIN LIST UPS')) { + continue; + } + // Format: UPS "" + const match = line.match(/^UPS\s+(\S+)\s+"(.*)"/); + if (match) { + names.push(match[1]); + } + } + + return names; +} diff --git a/src/lib/server/integrations/nut/index.ts b/src/lib/server/integrations/nut/index.ts new file mode 100644 index 0000000..0787ae9 --- /dev/null +++ b/src/lib/server/integrations/nut/index.ts @@ -0,0 +1,110 @@ +import type { Integration, IntegrationData, IntegrationEndpoint } from '../types.js'; +import { nutAuthConfigSchema, type NutAuthConfig } from './schema.js'; +import { listVariables, listUps } from './client.js'; +import { toBatteryGauge, toLoadGauge, toRuntimeCard, toStatusAlert } from './transform.js'; +import { wrapError } from '../base.js'; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'battery-status', + name: 'Battery Status', + description: 'Battery charge percentage', + renderer: 'gauge', + refreshInterval: 30 + }, + { + id: 'load', + name: 'UPS Load', + description: 'Current UPS load percentage', + renderer: 'gauge', + refreshInterval: 30 + }, + { + id: 'runtime', + name: 'Runtime Remaining', + description: 'Estimated battery runtime', + renderer: 'stat-card', + refreshInterval: 30 + }, + { + id: 'ups-status', + name: 'UPS Status', + description: 'Current UPS status with battery alert', + renderer: 'alert-banner', + refreshInterval: 15 + } +]; + +async function getVarsMap(config: NutAuthConfig): Promise> { + const vars = await listVariables(config.nutHost, config.nutPort, config.upsName); + return new Map(vars.map((v) => [v.name, v.value])); +} + +export const nutIntegration: Integration = { + id: 'nut', + name: 'NUT (UPS)', + icon: 'battery-charging', + description: 'Monitor UPS battery, load, and power status via NUT protocol', + authConfigSchema: nutAuthConfigSchema, + endpoints, + + async testConnection(_appUrl: string, config: Record) { + try { + const parsed = nutAuthConfigSchema.parse(config); + const upsList = await listUps(parsed.nutHost, parsed.nutPort); + if (upsList.length === 0) { + return { success: false, message: 'Connected but no UPS devices found' }; + } + const found = upsList.includes(parsed.upsName); + return found + ? { success: true, message: `Connected. UPS "${parsed.upsName}" found.` } + : { + success: false, + message: `Connected but UPS "${parsed.upsName}" not found. Available: ${upsList.join(', ')}` + }; + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : 'Connection failed' + }; + } + }, + + async fetchData( + _appUrl: string, + config: Record, + endpointId: string + ): Promise { + try { + const parsed = nutAuthConfigSchema.parse(config); + const vars = await getVarsMap(parsed); + + let data; + let renderer; + switch (endpointId) { + case 'battery-status': + data = toBatteryGauge(vars); + renderer = 'gauge' as const; + break; + case 'load': + data = toLoadGauge(vars); + renderer = 'gauge' as const; + break; + case 'runtime': + data = toRuntimeCard(vars); + renderer = 'stat-card' as const; + break; + case 'ups-status': + data = toStatusAlert(vars); + renderer = 'alert-banner' as const; + break; + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + + return { endpointId, renderer, data, fetchedAt: new Date().toISOString() }; + } catch (err) { + throw wrapError('nut', endpointId, err); + } + } +}; diff --git a/src/lib/server/integrations/nut/schema.ts b/src/lib/server/integrations/nut/schema.ts new file mode 100644 index 0000000..2f57a95 --- /dev/null +++ b/src/lib/server/integrations/nut/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const nutAuthConfigSchema = z.object({ + nutHost: z.string().min(1, 'NUT host is required'), + nutPort: z.number().int().min(1).max(65535).default(3493), + upsName: z.string().min(1, 'UPS name is required') +}); + +export type NutAuthConfig = z.infer; diff --git a/src/lib/server/integrations/nut/transform.ts b/src/lib/server/integrations/nut/transform.ts new file mode 100644 index 0000000..fa7e182 --- /dev/null +++ b/src/lib/server/integrations/nut/transform.ts @@ -0,0 +1,57 @@ +import type { GaugeData, StatCardData, AlertBannerData } from '../types.js'; + +export function toBatteryGauge(vars: Map): GaugeData { + const charge = parseFloat(vars.get('battery.charge') ?? '0'); + return { + value: charge, + max: 100, + label: 'Battery', + unit: '%', + thresholds: { warning: 40, critical: 20 } + }; +} + +export function toLoadGauge(vars: Map): GaugeData { + const load = parseFloat(vars.get('ups.load') ?? '0'); + return { + value: load, + max: 100, + label: 'UPS Load', + unit: '%', + thresholds: { warning: 60, critical: 85 } + }; +} + +export function toRuntimeCard(vars: Map): StatCardData { + const seconds = parseInt(vars.get('battery.runtime') ?? '0', 10); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const formatted = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; + return { + label: 'Runtime Remaining', + value: formatted, + subtitle: `${seconds}s total` + }; +} + +export function toStatusAlert(vars: Map): AlertBannerData { + const status = vars.get('ups.status') ?? 'UNKNOWN'; + // OL = Online, OB = On Battery, LB = Low Battery, etc. + if (status.includes('OB') || status.includes('LB')) { + return { + severity: status.includes('LB') ? 'critical' : 'warning', + title: 'UPS On Battery Power', + message: status.includes('LB') + ? `Low battery! Status: ${status}. Charge: ${vars.get('battery.charge') ?? '?'}%` + : `Running on battery. Status: ${status}. Runtime: ${vars.get('battery.runtime') ?? '?'}s`, + icon: 'battery-warning' + }; + } + // Return info-level when online (will be filtered out by alerts endpoint) + return { + severity: 'info', + title: 'UPS Online', + message: `Status: ${status}. Charge: ${vars.get('battery.charge') ?? '?'}%`, + icon: 'battery-charging' + }; +} diff --git a/src/lib/server/integrations/pihole/client.ts b/src/lib/server/integrations/pihole/client.ts new file mode 100644 index 0000000..6158128 --- /dev/null +++ b/src/lib/server/integrations/pihole/client.ts @@ -0,0 +1,62 @@ +import { fetchWithTimeout } from '../base.js'; + +export interface PiholeSummary { + readonly dns_queries_today: string; + readonly ads_blocked_today: string; + readonly ads_percentage_today: string; + readonly unique_clients: string; + readonly domains_being_blocked: string; + readonly gravity_last_updated?: { + readonly relative?: { + readonly days: string; + readonly hours: string; + readonly minutes: string; + }; + }; + readonly status: string; +} + +export interface PiholeTopItems { + readonly top_queries: Record; + readonly top_ads: Record; +} + +export interface PiholeQueryEntry { + readonly [index: number]: string; +} + +function buildBaseUrl(appUrl: string): string { + return `${appUrl.replace(/\/$/, '')}/admin/api.php`; +} + +export async function fetchSummary(appUrl: string, apiToken: string): Promise { + const url = `${buildBaseUrl(appUrl)}?summary&auth=${apiToken}`; + const res = await fetchWithTimeout(url); + if (!res.ok) { + throw new Error(`Pi-hole API returned ${res.status}`); + } + return res.json(); +} + +export async function fetchTopItems(appUrl: string, apiToken: string): Promise { + const url = `${buildBaseUrl(appUrl)}?topItems=10&auth=${apiToken}`; + const res = await fetchWithTimeout(url); + if (!res.ok) { + throw new Error(`Pi-hole API returned ${res.status}`); + } + return res.json(); +} + +export async function fetchAllQueries( + appUrl: string, + apiToken: string, + count = 100 +): Promise { + const url = `${buildBaseUrl(appUrl)}?getAllQueries=${count}&auth=${apiToken}`; + const res = await fetchWithTimeout(url); + if (!res.ok) { + throw new Error(`Pi-hole API returned ${res.status}`); + } + const json = await res.json(); + return json.data ?? []; +} diff --git a/src/lib/server/integrations/pihole/index.ts b/src/lib/server/integrations/pihole/index.ts new file mode 100644 index 0000000..dcdf040 --- /dev/null +++ b/src/lib/server/integrations/pihole/index.ts @@ -0,0 +1,119 @@ +import type { Integration, IntegrationData, IntegrationEndpoint, IntegrationTestResult } from '../types.js'; +import { wrapError } from '../base.js'; +import { piholeAuthConfigSchema } from './schema.js'; +import { fetchSummary, fetchTopItems, fetchAllQueries } from './client.js'; +import { toStatsSummary, toTopBlocked, toQueryLog, toGravityStatus } from './transform.js'; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'stats-summary', + name: 'DNS Stats', + description: 'Queries, blocked, clients today', + renderer: 'stat-card', + refreshInterval: 60 + }, + { + id: 'top-blocked', + name: 'Top Blocked', + description: 'Most blocked domains', + renderer: 'list', + refreshInterval: 300 + }, + { + id: 'query-log', + name: 'Query Log', + description: 'Recent DNS queries', + renderer: 'list', + refreshInterval: 30 + }, + { + id: 'gravity-status', + name: 'Gravity Status', + description: 'Blocklist status', + renderer: 'stat-card', + refreshInterval: 3600 + } +] as const; + +async function testConnection( + appUrl: string, + config: Record +): Promise { + try { + const { apiToken } = piholeAuthConfigSchema.parse(config); + const summary = await fetchSummary(appUrl, apiToken); + + if (summary.status === 'disabled') { + return { success: true, message: 'Connected to Pi-hole (blocking is currently disabled)' }; + } + + return { success: true, message: `Connected — ${summary.dns_queries_today} queries today` }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, message: `Failed to connect to Pi-hole: ${message}` }; + } +} + +async function fetchData( + appUrl: string, + config: Record, + endpointId: string +): Promise { + const { apiToken } = piholeAuthConfigSchema.parse(config); + + try { + switch (endpointId) { + case 'stats-summary': { + const summary = await fetchSummary(appUrl, apiToken); + return { + endpointId, + renderer: 'stat-card', + data: toStatsSummary(summary), + fetchedAt: new Date().toISOString() + }; + } + case 'top-blocked': { + const topItems = await fetchTopItems(appUrl, apiToken); + return { + endpointId, + renderer: 'list', + data: toTopBlocked(topItems), + fetchedAt: new Date().toISOString() + }; + } + case 'query-log': { + const queries = await fetchAllQueries(appUrl, apiToken); + return { + endpointId, + renderer: 'list', + data: toQueryLog(queries), + fetchedAt: new Date().toISOString() + }; + } + case 'gravity-status': { + const summary = await fetchSummary(appUrl, apiToken); + return { + endpointId, + renderer: 'stat-card', + data: toGravityStatus(summary), + fetchedAt: new Date().toISOString() + }; + } + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + } catch (error) { + throw wrapError('pihole', endpointId, error); + } +} + +export const piholeIntegration: Integration = { + id: 'pihole', + name: 'Pi-hole', + icon: 'pi-hole', + description: 'DNS ad-blocking statistics and query log', + authConfigSchema: piholeAuthConfigSchema, + endpoints, + testConnection, + fetchData +}; diff --git a/src/lib/server/integrations/pihole/schema.ts b/src/lib/server/integrations/pihole/schema.ts new file mode 100644 index 0000000..0b5cba2 --- /dev/null +++ b/src/lib/server/integrations/pihole/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const piholeAuthConfigSchema = z.object({ + apiToken: z.string().min(1, 'API token is required') +}); + +export type PiholeAuthConfig = z.infer; diff --git a/src/lib/server/integrations/pihole/transform.ts b/src/lib/server/integrations/pihole/transform.ts new file mode 100644 index 0000000..7968894 --- /dev/null +++ b/src/lib/server/integrations/pihole/transform.ts @@ -0,0 +1,77 @@ +import type { StatCardData, ListData } from '../types.js'; +import type { PiholeSummary, PiholeTopItems, PiholeQueryEntry } from './client.js'; + +export function toStatsSummary(summary: PiholeSummary): StatCardData { + const queries = Number(summary.dns_queries_today).toLocaleString(); + const blocked = Number(summary.ads_blocked_today).toLocaleString(); + const percentage = summary.ads_percentage_today; + const clients = summary.unique_clients; + + return { + label: 'DNS Blocked Today', + value: blocked, + unit: 'queries', + subtitle: `${queries} queries | ${percentage}% blocked | ${clients} clients` + }; +} + +export function toTopBlocked(topItems: PiholeTopItems): ListData { + const entries = Object.entries(topItems.top_ads ?? {}); + + return { + items: entries.map(([domain, count]) => ({ + id: domain, + title: domain, + badge: { text: String(count), color: 'red' } + })) + }; +} + +export function toQueryLog(queries: readonly PiholeQueryEntry[]): ListData { + return { + items: queries.map((entry, index) => { + // Pi-hole getAllQueries returns arrays: + // [0] timestamp, [1] type, [2] domain, [3] client, [4] status + const row = entry as unknown as readonly string[]; + const domain = row[2] ?? 'unknown'; + const client = row[3] ?? 'unknown'; + const statusCode = Number(row[4] ?? 0); + const isBlocked = statusCode === 1 || statusCode === 4 || statusCode === 5 || statusCode === 9 || statusCode === 10 || statusCode === 11; + + return { + id: `query-${index}`, + title: domain, + subtitle: client, + badge: isBlocked + ? { text: 'Blocked', color: 'red' } + : { text: 'Allowed', color: 'green' } + }; + }) + }; +} + +export function toGravityStatus(summary: PiholeSummary): StatCardData { + const domainsBlocked = Number(summary.domains_being_blocked).toLocaleString(); + const gravity = summary.gravity_last_updated?.relative; + let subtitle = 'Last update unknown'; + + if (gravity) { + const days = Number(gravity.days); + const hours = Number(gravity.hours); + const minutes = Number(gravity.minutes); + const parts: string[] = []; + + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); + + subtitle = `Last updated ${parts.join(' ')} ago`; + } + + return { + label: 'Gravity Blocklist', + value: domainsBlocked, + unit: 'domains', + subtitle + }; +} diff --git a/src/lib/server/integrations/portainer/client.ts b/src/lib/server/integrations/portainer/client.ts new file mode 100644 index 0000000..75a57ba --- /dev/null +++ b/src/lib/server/integrations/portainer/client.ts @@ -0,0 +1,69 @@ +import { fetchWithTimeout } from '../base.js'; + +export interface DockerContainer { + readonly Id: string; + readonly Names: readonly string[]; + readonly State: string; + readonly Status: string; + readonly Image: string; + readonly Created: number; +} + +export interface PortainerStack { + readonly Id: number; + readonly Name: string; + readonly Status: number; + readonly EndpointId: number; +} + +export interface PortainerEndpointInfo { + readonly Id: number; + readonly Name: string; + readonly Type: number; +} + +function buildBaseUrl(appUrl: string): string { + return `${appUrl.replace(/\/$/, '')}/api`; +} + +function authHeaders(apiKey: string): Record { + return { 'X-API-Key': apiKey }; +} + +export async function fetchContainers( + appUrl: string, + apiKey: string, + endpointId: number +): Promise { + const url = `${buildBaseUrl(appUrl)}/endpoints/${endpointId}/docker/containers/json?all=true`; + const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) }); + if (!res.ok) { + throw new Error(`Portainer API returned ${res.status}`); + } + return res.json(); +} + +export async function fetchStacks( + appUrl: string, + apiKey: string, + endpointId: number +): Promise { + const url = `${buildBaseUrl(appUrl)}/stacks?EndpointID=${endpointId}`; + const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) }); + if (!res.ok) { + throw new Error(`Portainer API returned ${res.status}`); + } + return res.json(); +} + +export async function fetchEndpoints( + appUrl: string, + apiKey: string +): Promise { + const url = `${buildBaseUrl(appUrl)}/endpoints`; + const res = await fetchWithTimeout(url, { headers: authHeaders(apiKey) }); + if (!res.ok) { + throw new Error(`Portainer API returned ${res.status}`); + } + return res.json(); +} diff --git a/src/lib/server/integrations/portainer/index.ts b/src/lib/server/integrations/portainer/index.ts new file mode 100644 index 0000000..7dec03d --- /dev/null +++ b/src/lib/server/integrations/portainer/index.ts @@ -0,0 +1,99 @@ +import type { Integration, IntegrationData, IntegrationEndpoint } from '../types.js'; +import { portainerAuthConfigSchema, type PortainerAuthConfig } from './schema.js'; +import { fetchContainers, fetchStacks, fetchEndpoints } from './client.js'; +import { toContainerSummary, toContainerList, toStackStatus } from './transform.js'; +import { wrapError } from '../base.js'; + +const endpoints: readonly IntegrationEndpoint[] = [ + { + id: 'container-summary', + name: 'Container Summary', + description: 'Running/stopped/error counts', + renderer: 'stat-card', + refreshInterval: 30 + }, + { + id: 'container-list', + name: 'Container List', + description: 'All containers with status', + renderer: 'list', + refreshInterval: 30 + }, + { + id: 'stack-status', + name: 'Stack Status', + description: 'Docker Compose stack status', + renderer: 'list', + refreshInterval: 60 + } +]; + +export const portainerIntegration: Integration = { + id: 'portainer', + name: 'Portainer', + icon: 'container', + description: 'Monitor Docker containers and stacks via Portainer API', + authConfigSchema: portainerAuthConfigSchema, + endpoints, + + async testConnection(appUrl: string, config: Record) { + try { + const parsed = portainerAuthConfigSchema.parse(config); + const endpointsList = await fetchEndpoints(appUrl, parsed.apiKey); + if (endpointsList.length === 0) { + return { success: false, message: 'Connected but no endpoints found' }; + } + const found = endpointsList.some((e) => e.Id === parsed.endpointId); + return found + ? { success: true, message: `Connected. Endpoint ${parsed.endpointId} found.` } + : { + success: false, + message: `Connected but endpoint ${parsed.endpointId} not found. Available: ${endpointsList.map((e) => `${e.Id} (${e.Name})`).join(', ')}` + }; + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : 'Connection failed' + }; + } + }, + + async fetchData( + appUrl: string, + config: Record, + endpointId: string + ): Promise { + try { + const parsed: PortainerAuthConfig = portainerAuthConfigSchema.parse(config); + + let data; + let renderer; + switch (endpointId) { + case 'container-summary': { + const containers = await fetchContainers(appUrl, parsed.apiKey, parsed.endpointId); + data = toContainerSummary(containers); + renderer = 'stat-card' as const; + break; + } + case 'container-list': { + const containers = await fetchContainers(appUrl, parsed.apiKey, parsed.endpointId); + data = toContainerList(containers); + renderer = 'list' as const; + break; + } + case 'stack-status': { + const stacks = await fetchStacks(appUrl, parsed.apiKey, parsed.endpointId); + data = toStackStatus(stacks); + renderer = 'list' as const; + break; + } + default: + throw new Error(`Unknown endpoint: ${endpointId}`); + } + + return { endpointId, renderer, data, fetchedAt: new Date().toISOString() }; + } catch (err) { + throw wrapError('portainer', endpointId, err); + } + } +}; diff --git a/src/lib/server/integrations/portainer/schema.ts b/src/lib/server/integrations/portainer/schema.ts new file mode 100644 index 0000000..e4643fd --- /dev/null +++ b/src/lib/server/integrations/portainer/schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const portainerAuthConfigSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + endpointId: z.number().int().min(1).default(1) +}); + +export type PortainerAuthConfig = z.infer; diff --git a/src/lib/server/integrations/portainer/transform.ts b/src/lib/server/integrations/portainer/transform.ts new file mode 100644 index 0000000..dce899a --- /dev/null +++ b/src/lib/server/integrations/portainer/transform.ts @@ -0,0 +1,66 @@ +import type { StatCardData, ListData } from '../types.js'; +import type { DockerContainer, PortainerStack } from './client.js'; + +const MAX_CONTAINER_LIST = 50; + +function containerDisplayName(container: DockerContainer): string { + const raw = container.Names[0] ?? container.Id.slice(0, 12); + return raw.replace(/^\//, ''); +} + +function containerStateBadge(state: string): { readonly text: string; readonly color: string } { + switch (state.toLowerCase()) { + case 'running': + return { text: 'running', color: 'green' }; + case 'exited': + case 'dead': + return { text: state.toLowerCase(), color: 'red' }; + case 'paused': + return { text: 'paused', color: 'yellow' }; + case 'restarting': + return { text: 'restarting', color: 'yellow' }; + case 'created': + return { text: 'created', color: 'yellow' }; + default: + return { text: state.toLowerCase(), color: 'red' }; + } +} + +export function toContainerSummary(containers: readonly DockerContainer[]): StatCardData { + const running = containers.filter((c) => c.State.toLowerCase() === 'running').length; + const stopped = containers.filter((c) => c.State.toLowerCase() === 'exited').length; + const error = containers.filter((c) => + !['running', 'exited', 'paused', 'created'].includes(c.State.toLowerCase()) + ).length; + + return { + label: 'Containers', + value: running, + subtitle: `${running} running, ${stopped} stopped, ${error} error` + }; +} + +export function toContainerList(containers: readonly DockerContainer[]): ListData { + const limited = containers.slice(0, MAX_CONTAINER_LIST); + + return { + items: limited.map((c) => ({ + id: c.Id, + title: containerDisplayName(c), + subtitle: c.Image, + badge: containerStateBadge(c.State) + })) + }; +} + +export function toStackStatus(stacks: readonly PortainerStack[]): ListData { + return { + items: stacks.map((s) => ({ + id: String(s.Id), + title: s.Name, + badge: s.Status === 1 + ? { text: 'active', color: 'green' } + : { text: 'inactive', color: 'red' } + })) + }; +} diff --git a/src/lib/server/integrations/registry.ts b/src/lib/server/integrations/registry.ts index 07becc7..b2bdb5b 100644 --- a/src/lib/server/integrations/registry.ts +++ b/src/lib/server/integrations/registry.ts @@ -1,5 +1,11 @@ import type { Integration, IntegrationInfo, IntegrationFieldDescriptor } from './types.js'; import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod'; +import { nutIntegration } from './nut/index.js'; +import { piholeIntegration } from './pihole/index.js'; +import { portainerIntegration } from './portainer/index.js'; +import { giteaIntegration } from './gitea/index.js'; +import { npmIntegration } from './npm/index.js'; +import { authentikIntegration } from './authentik/index.js'; const integrations = new Map(); @@ -60,3 +66,11 @@ export function listInfo(): readonly IntegrationInfo[] { extraConfigFields: zodSchemaToFields(i.extraConfigSchema) })); } + +// Auto-register integrations +register(nutIntegration); +register(piholeIntegration); +register(portainerIntegration); +register(giteaIntegration); +register(npmIntegration); +register(authentikIntegration);