feat(service-integrations): phases 3-8 — six service integrations
- NUT/UPS: TCP protocol client, battery/load gauges, runtime card, power alert banner - Pi-hole: DNS stats summary, top blocked domains, query log, gravity status - Portainer: Container summary/list, stack status via Docker API - Gitea: Recent commits, open PRs, releases across repos - Nginx Proxy Manager: Proxy hosts, SSL certificate expiry alerts, upstream status - Authentik: User stats, login events feed, brute force detection alerts - All integrations registered in registry with auto-discovery
This commit is contained in:
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuthentikPaginatedResponse<T> {
|
||||
readonly pagination: { readonly count: number; readonly total_pages: number };
|
||||
readonly results: readonly T[];
|
||||
}
|
||||
|
||||
function buildHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrl(appUrl: string): string {
|
||||
return appUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export async function fetchUserCount(appUrl: string, token: string): Promise<number> {
|
||||
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<unknown> = await res.json();
|
||||
return json.pagination.count;
|
||||
}
|
||||
|
||||
export async function fetchLoginEvents(
|
||||
appUrl: string,
|
||||
token: string,
|
||||
limit = 20
|
||||
): Promise<readonly AuthentikEvent[]> {
|
||||
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<AuthentikEvent> = await res.json();
|
||||
return json.results;
|
||||
}
|
||||
|
||||
export async function fetchFailedLogins(
|
||||
appUrl: string,
|
||||
token: string,
|
||||
limit = 50
|
||||
): Promise<readonly AuthentikEvent[]> {
|
||||
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<AuthentikEvent> = await res.json();
|
||||
return json.results;
|
||||
}
|
||||
|
||||
export async function testAuth(appUrl: string, token: string): Promise<boolean> {
|
||||
const url = `${normalizeUrl(appUrl)}/api/v3/core/tokens/?page_size=1`;
|
||||
const res = await fetchWithTimeout(url, { headers: buildHeaders(token) });
|
||||
return res.ok;
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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
|
||||
};
|
||||
@@ -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<typeof authentikAuthConfigSchema>;
|
||||
export type AuthentikExtraConfig = z.infer<typeof authentikExtraConfigSchema>;
|
||||
@@ -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<string, number>();
|
||||
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'
|
||||
};
|
||||
}
|
||||
@@ -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<T>(appUrl: string, token: string, path: string): Promise<T> {
|
||||
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<GiteaUser> {
|
||||
return authedRequest<GiteaUser>(appUrl, token, '/user');
|
||||
}
|
||||
|
||||
export async function fetchRepos(
|
||||
appUrl: string,
|
||||
token: string,
|
||||
repoFilter?: readonly string[]
|
||||
): Promise<GiteaRepo[]> {
|
||||
const repos = await authedRequest<GiteaRepo[]>(
|
||||
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<GiteaCommit[]> {
|
||||
return authedRequest<GiteaCommit[]>(
|
||||
appUrl,
|
||||
token,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits?limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPullRequests(
|
||||
appUrl: string,
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<GiteaPullRequest[]> {
|
||||
return authedRequest<GiteaPullRequest[]>(
|
||||
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<GiteaRelease[]> {
|
||||
return authedRequest<GiteaRelease[]>(
|
||||
appUrl,
|
||||
token,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases?limit=5`
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof giteaAuthConfigSchema>;
|
||||
export type GiteaExtraConfig = z.infer<typeof giteaExtraConfigSchema>;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
const tokenCache = new Map<string, { readonly token: string; readonly expiresAt: number }>();
|
||||
|
||||
function buildBaseUrl(appUrl: string): string {
|
||||
return `${appUrl.replace(/\/$/, '')}/api`;
|
||||
}
|
||||
|
||||
export async function getToken(
|
||||
appUrl: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
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<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticatedFetch(
|
||||
appUrl: string,
|
||||
path: string,
|
||||
token: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<Response> {
|
||||
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<NpmProxyHost[]> {
|
||||
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<NpmCertificate[]> {
|
||||
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();
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): Promise<IntegrationTestResult> {
|
||||
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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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
|
||||
};
|
||||
@@ -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<typeof npmAuthConfigSchema>;
|
||||
@@ -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`
|
||||
};
|
||||
}
|
||||
@@ -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<readonly string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection({ host, port });
|
||||
const lines: string[] = [];
|
||||
let buffer = '';
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let readTimer: ReturnType<typeof setTimeout> | 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 <upsName> <varName> "<value>"
|
||||
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<string> {
|
||||
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<readonly NutVariable[]> {
|
||||
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<readonly string[]> {
|
||||
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 <upsName> "<description>"
|
||||
const match = line.match(/^UPS\s+(\S+)\s+"(.*)"/);
|
||||
if (match) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
@@ -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<Map<string, string>> {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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<typeof nutAuthConfigSchema>;
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { GaugeData, StatCardData, AlertBannerData } from '../types.js';
|
||||
|
||||
export function toBatteryGauge(vars: Map<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): 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'
|
||||
};
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
readonly top_ads: Record<string, number>;
|
||||
}
|
||||
|
||||
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<PiholeSummary> {
|
||||
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<PiholeTopItems> {
|
||||
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<readonly PiholeQueryEntry[]> {
|
||||
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 ?? [];
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): Promise<IntegrationTestResult> {
|
||||
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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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
|
||||
};
|
||||
@@ -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<typeof piholeAuthConfigSchema>;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
return { 'X-API-Key': apiKey };
|
||||
}
|
||||
|
||||
export async function fetchContainers(
|
||||
appUrl: string,
|
||||
apiKey: string,
|
||||
endpointId: number
|
||||
): Promise<DockerContainer[]> {
|
||||
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<PortainerStack[]> {
|
||||
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<PortainerEndpointInfo[]> {
|
||||
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();
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
endpointId: string
|
||||
): Promise<IntegrationData> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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<typeof portainerAuthConfigSchema>;
|
||||
@@ -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' }
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -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<string, Integration>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user