Add webhook notification feature for extractor expiry and storage capacity alerts

This commit is contained in:
2025-09-21 15:10:33 +02:00
parent 82877b8870
commit 9c8ade4c75
9 changed files with 435 additions and 26 deletions

View File

@@ -5,3 +5,4 @@ EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at
NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no
SENTRY_AUTH_TOKEN=Sentry token for error reporting.
LOG_LEVEL=warn
WEBHOOK_URL=Webhook URL

View File

@@ -20,6 +20,7 @@ Features:
- Backup to download characters to a file
- Rstore from a file. Must be from the same instance!
- View the 3D render of the planet with your PI setup by clicking the planet
- Webhook notifications for extractor expiry, storage capacity, and launch pad capacity
## Support with hosting
@@ -65,6 +66,7 @@ You will need these env variables from the application settings:
EVE_SSO_CLIENT_ID=Client ID
EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
WEBHOOK_URL=Discord webhook URL for notifications (optional)
```
## Run locally
@@ -72,6 +74,25 @@ EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at
1. Create .env file in the directory root and populate with env variables you get from the EVE app you created. Example env file: .env.example
2. run `npm run dev`
## Webhook Notifications
The application supports webhook notifications for various PI events:
- **Extractor expiry warnings**: Notify when extractors are about to run out (configurable hours before expiry)
- **Extractor expired**: Notify when extractors have run out
- **Storage capacity warnings**: Notify when storage is about to be full (configurable percentage)
- **Storage full**: Notify when storage is at 100% capacity
- **Launch pad capacity warnings**: Notify when launch pads are about to be full (configurable percentage)
- **Launch pad full**: Notify when launch pads are at 100% capacity
To enable webhook notifications:
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL
2. Enable webhook notifications in the Settings dialog
3. Configure warning thresholds for extractors, storage, and launch pads
The webhook URL is kept secure in environment variables and not stored in the browser.
## Run the container
1. Populate the environment variables in .env file

View File

@@ -8,6 +8,7 @@ services:
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
- NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- WEBHOOK_URL=${WEBHOOK_URL}
- LOG_LEVEL=warn
ports:
- 3000:3000

View File

@@ -1,27 +1,27 @@
import {
ColorContext,
ColorSelectionType,
SessionContext,
ColorContext,
ColorSelectionType,
SessionContext,
} from "@/app/context/Context";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tooltip,
Typography,
TextField,
Box,
FormControlLabel,
Checkbox,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Tooltip,
Typography,
TextField,
Box,
FormControlLabel,
Checkbox,
} from "@mui/material";
import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color";
import { ColorResult, CompactPicker } from "react-color";
import React, { useState, useContext } from "react";
export const SettingsButton = () => {
const { colors, setColors } = useContext(ColorContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons } = useContext(SessionContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -51,6 +51,44 @@ export const SettingsButton = () => {
setShowProductIcons(event.target.checked);
};
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebhookConfig({
...webhookConfig,
enabled: event.target.checked
});
};
const handleExtractorWarningHoursChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 168) { // Max 1 week
setWebhookConfig({
...webhookConfig,
extractorWarningHours: value
});
}
};
const handleStorageWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig({
...webhookConfig,
storageWarningPercent: value
});
}
};
const handleLaunchpadWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig({
...webhookConfig,
launchpadWarningPercent: value
});
}
};
return (
<Tooltip title="Toggle settings dialog">
<>
@@ -93,6 +131,58 @@ export const SettingsButton = () => {
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Webhook Notifications</Typography>
<FormControlLabel
control={
<Checkbox
checked={webhookConfig.enabled}
onChange={handleWebhookEnabledChange}
/>
}
label="Enable webhook notifications"
/>
{webhookConfig.enabled && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Webhook URL is configured via the WEBHOOK_URL environment variable.
</Typography>
<TextField
type="number"
label="Extractor Warning Hours"
value={webhookConfig.extractorWarningHours}
onChange={handleExtractorWarningHoursChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 168 }}
helperText="Hours before extractor expiry to send warning (0-168)"
error={webhookConfig.extractorWarningHours < 0 || webhookConfig.extractorWarningHours > 168}
/>
<TextField
type="number"
label="Storage Warning %"
value={webhookConfig.storageWarningPercent}
onChange={handleStorageWarningPercentChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Storage capacity percentage to trigger warning (0-100)"
error={webhookConfig.storageWarningPercent < 0 || webhookConfig.storageWarningPercent > 100}
/>
<TextField
type="number"
label="Launchpad Warning %"
value={webhookConfig.launchpadWarningPercent}
onChange={handleLaunchpadWarningPercentChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Launchpad capacity percentage to trigger warning (0-100)"
error={webhookConfig.launchpadWarningPercent < 0 || webhookConfig.launchpadWarningPercent > 100}
/>
</>
)}
</Box>
{Object.keys(colors).map((key) => {
return (
<div key={`color-row-${key}`}>

View File

@@ -1,5 +1,5 @@
import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { AccessToken, CharacterUpdate, PlanetConfig, WebhookConfig } from "@/types";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
@@ -41,6 +41,8 @@ export const SessionContext = createContext<{
setBalanceThreshold: Dispatch<SetStateAction<number>>;
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
webhookConfig: WebhookConfig;
setWebhookConfig: (config: WebhookConfig) => void;
}>({
sessionReady: false,
refreshSession: () => {},
@@ -70,6 +72,13 @@ export const SessionContext = createContext<{
setBalanceThreshold: () => {},
showProductIcons: false,
setShowProductIcons: () => {},
webhookConfig: {
enabled: false,
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
},
setWebhookConfig: () => {},
});
export type ColorSelectionType = {

View File

@@ -4,20 +4,21 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import { memo, useCallback, useEffect, useState, Suspense } from "react";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo, WebhookConfig } from "../types";
import { MainGrid } from "./components/MainGrid";
import { refreshToken } from "@/esi-sso";
import {
CharacterContext,
ColorContext,
ColorSelectionType,
SessionContext,
defaultColors,
CharacterContext,
ColorContext,
ColorSelectionType,
SessionContext,
defaultColors,
} from "./context/Context";
import { useSearchParams } from "next/navigation";
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
import { PlanetConfig } from "@/types";
import { notificationService } from "@/utils/notificationService";
// Add batch processing utility
const processInBatches = async <T, R>(
@@ -47,6 +48,12 @@ const Home = () => {
const [balanceThreshold, setBalanceThreshold] = useState(1000);
const [showProductIcons, setShowProductIcons] = useState(false);
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>({
enabled: false,
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
});
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false);
@@ -253,6 +260,17 @@ const Home = () => {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}, [extractionTimeMode]);
useEffect(() => {
const storedWebhookConfig = localStorage.getItem("webhookConfig");
if (storedWebhookConfig) {
setWebhookConfig(JSON.parse(storedWebhookConfig));
}
}, []);
useEffect(() => {
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
}, [webhookConfig]);
useEffect(() => {
fetch("api/env")
.then((r) => r.json())
@@ -284,10 +302,14 @@ const Home = () => {
refreshSession(characters)
.then(saveCharacters)
.then(initializeCharacterPlanets)
.then(setCharacters);
.then((updatedCharacters) => {
setCharacters(updatedCharacters);
// Check for notifications after updating characters
notificationService.checkAndNotify(updatedCharacters, webhookConfig);
});
}, ESI_CACHE_TIME_MS);
return () => clearInterval(interval);
});
}, [webhookConfig]);
return (
<SessionContext.Provider
@@ -312,6 +334,8 @@ const Home = () => {
setBalanceThreshold,
showProductIcons,
setShowProductIcons,
webhookConfig,
setWebhookConfig,
}}
>
<CharacterContext.Provider

67
src/pages/api/webhook.ts Normal file
View File

@@ -0,0 +1,67 @@
import { NextApiRequest, NextApiResponse } from "next";
import logger from "@/utils/logger";
interface WebhookPayload {
content: string;
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
const { content }: { content: string } = req.body;
if (!content) {
return res.status(400).json({ error: "Content is required" });
}
const webhookUrl = process.env.WEBHOOK_URL;
if (!webhookUrl) {
return res.status(500).json({ error: "Webhook URL not configured" });
}
const payload: WebhookPayload = { content };
logger.info({
event: 'webhook_send_start',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'), // Mask the webhook token
contentLength: content.length
});
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Webhook request failed with status ${response.status}`);
}
logger.info({
event: 'webhook_send_success',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'),
status: response.status
});
return res.json({ success: true });
} catch (error) {
logger.error({
event: 'webhook_send_failed',
error: error instanceof Error ? error.message : String(error),
body: req.body
});
return res.status(500).json({ error: "Failed to send webhook" });
}
} else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end();
}
};
export default handler;

View File

@@ -123,3 +123,19 @@ export interface PlanetConfig {
planetId: number;
excludeFromTotals: boolean;
}
export interface WebhookConfig {
enabled: boolean;
extractorWarningHours: number;
storageWarningPercent: number;
launchpadWarningPercent: number;
}
export interface NotificationState {
extractorExpired: Set<string>;
extractorWarning: Set<string>;
storageFull: Set<string>;
storageWarning: Set<string>;
launchpadFull: Set<string>;
launchpadWarning: Set<string>;
}

View File

@@ -0,0 +1,180 @@
import { DateTime } from "luxon";
import { AccessToken, WebhookConfig, NotificationState, PlanetCalculations, PlanetWithInfo } from "@/types";
import { planetCalculations } from "@/planets";
import { LAUNCHPAD_IDS } from "@/const";
export class NotificationService {
private notificationState: NotificationState = {
extractorExpired: new Set(),
extractorWarning: new Set(),
storageFull: new Set(),
storageWarning: new Set(),
launchpadFull: new Set(),
launchpadWarning: new Set(),
};
private sendWebhook = async (content: string): Promise<void> => {
try {
const response = await fetch("/api/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`Webhook request failed with status ${response.status}`);
}
} catch (error) {
console.error("Failed to send webhook:", error);
}
};
private getPlanetIdentifier = (characterName: string, planetId: number): string => {
return `${characterName}-${planetId}`;
};
private checkExtractorExpiry = (
character: AccessToken,
planet: PlanetWithInfo,
planetCalculations: PlanetCalculations,
webhookConfig: WebhookConfig
): string[] => {
const notifications: string[] = [];
const now = DateTime.now();
const warningTime = now.plus({ hours: webhookConfig.extractorWarningHours });
planetCalculations.extractors.forEach((extractor) => {
if (!extractor.expiry_time) return;
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const planetId = planet.planet_id;
const identifier = this.getPlanetIdentifier(character.character.name, planetId);
// Check if extractor has expired
if (expiryTime <= now) {
if (!this.notificationState.extractorExpired.has(identifier)) {
this.notificationState.extractorExpired.add(identifier);
notifications.push(
`🚨 **EXTRACTOR EXPIRED** - ${character.character.name} Planet ${planetId} - Extractor expired at ${expiryTime.toFormat("yyyy-MM-dd HH:mm")}`
);
}
} else {
// Remove from expired set if it's no longer expired
this.notificationState.extractorExpired.delete(identifier);
}
// Check if extractor is about to expire (warning)
if (expiryTime <= warningTime && expiryTime > now) {
if (!this.notificationState.extractorWarning.has(identifier)) {
this.notificationState.extractorWarning.add(identifier);
const hoursLeft = Math.round(expiryTime.diff(now, "hours").hours);
notifications.push(
`⚠️ **EXTRACTOR WARNING** - ${character.character.name} Planet ${planetId} - Extractor expires in ${hoursLeft} hours (${expiryTime.toFormat("yyyy-MM-dd HH:mm")})`
);
}
} else {
// Remove from warning set if it's no longer in warning period
this.notificationState.extractorWarning.delete(identifier);
}
});
return notifications;
};
private checkStorageCapacity = (
character: AccessToken,
planet: PlanetWithInfo,
planetCalculations: PlanetCalculations,
webhookConfig: WebhookConfig
): string[] => {
const notifications: string[] = [];
planetCalculations.storageInfo.forEach((storage) => {
const planetId = planet.planet_id;
const identifier = this.getPlanetIdentifier(character.character.name, planetId);
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const warningThreshold = isLaunchpad ? webhookConfig.launchpadWarningPercent : webhookConfig.storageWarningPercent;
// Check if storage is full (100%)
if (storage.fillRate >= 100) {
const storageType = isLaunchpad ? "Launchpad" : "Storage";
if (!this.notificationState.storageFull.has(identifier)) {
this.notificationState.storageFull.add(identifier);
notifications.push(
`🚨 **${storageType.toUpperCase()} FULL** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity`
);
}
} else {
this.notificationState.storageFull.delete(identifier);
}
// Check if storage is about to be full (warning threshold)
if (storage.fillRate >= warningThreshold && storage.fillRate < 100) {
const storageType = isLaunchpad ? "Launchpad" : "Storage";
if (!this.notificationState.storageWarning.has(identifier)) {
this.notificationState.storageWarning.add(identifier);
notifications.push(
`⚠️ **${storageType.toUpperCase()} WARNING** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity`
);
}
} else {
this.notificationState.storageWarning.delete(identifier);
}
});
return notifications;
};
public checkAndNotify = async (
characters: AccessToken[],
webhookConfig: WebhookConfig
): Promise<void> => {
if (!webhookConfig.enabled) {
return;
}
// Get webhook URL from environment variables
const webhookUrl = process.env.WEBHOOK_URL;
if (!webhookUrl) {
console.warn("Webhook notifications enabled but WEBHOOK_URL environment variable not set");
return;
}
const allNotifications: string[] = [];
for (const character of characters) {
for (const planet of character.planets) {
const planetDetails = planetCalculations(planet);
// Check extractor expiry
const extractorNotifications = this.checkExtractorExpiry(character, planet, planetDetails, webhookConfig);
allNotifications.push(...extractorNotifications);
// Check storage capacity
const storageNotifications = this.checkStorageCapacity(character, planet, planetDetails, webhookConfig);
allNotifications.push(...storageNotifications);
}
}
// Send all notifications in a single webhook
if (allNotifications.length > 0) {
const content = allNotifications.join("\n");
await this.sendWebhook(content);
}
};
public resetNotificationState = (): void => {
this.notificationState = {
extractorExpired: new Set(),
extractorWarning: new Set(),
storageFull: new Set(),
storageWarning: new Set(),
launchpadFull: new Set(),
launchpadWarning: new Set(),
};
};
}
export const notificationService = new NotificationService();