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:
2026-03-25 22:12:31 +03:00
parent 50e8519220
commit d73fb9c680
25 changed files with 1847 additions and 0 deletions
@@ -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'
};
}
+116
View File
@@ -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`
);
}
+186
View File
@@ -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 };
}
+117
View File
@@ -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();
}
+105
View File
@@ -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`
};
}
+186
View File
@@ -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;
}
+110
View File
@@ -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 ?? [];
}
+119
View File
@@ -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' }
}))
};
}
+14
View File
@@ -1,5 +1,11 @@
import type { Integration, IntegrationInfo, IntegrationFieldDescriptor } from './types.js'; import type { Integration, IntegrationInfo, IntegrationFieldDescriptor } from './types.js';
import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod'; 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>(); const integrations = new Map<string, Integration>();
@@ -60,3 +66,11 @@ export function listInfo(): readonly IntegrationInfo[] {
extraConfigFields: zodSchemaToFields(i.extraConfigSchema) extraConfigFields: zodSchemaToFields(i.extraConfigSchema)
})); }));
} }
// Auto-register integrations
register(nutIntegration);
register(piholeIntegration);
register(portainerIntegration);
register(giteaIntegration);
register(npmIntegration);
register(authentikIntegration);