Compare commits

..

20 Commits

Author SHA1 Message Date
Sparky
43cabd2722 Add tmux run script 2025-10-10 11:59:25 +01:00
598044c811 Update 2025-10-10 12:57:52 +02:00
bb946e9e6a Update 2025-10-10 12:56:25 +02:00
aacae79769 Update 2025-10-10 12:48:45 +02:00
569ecf03e8 Migrate from discord to zulip 2025-10-10 12:40:34 +02:00
0a5c256569 Add ESI data refresh functionality to settings buttons 2025-09-23 10:04:27 +02:00
f081b3aba2 Implement persistent webhook configuration and add data refresh functionality in settings 2025-09-22 00:12:49 +02:00
b5dc367713 Stop spamming notifications 2025-09-21 16:13:59 +02:00
b56a950f04 Add webhook notification functionality with configuration options 2025-09-21 16:10:16 +02:00
46289d4667 Add WEBHOOK_URL environment variable and update documentation for webhook notifications 2025-09-21 15:31:11 +02:00
82877b8870 Add lockfile 2025-09-21 14:59:28 +02:00
calli
470935fe9d update official instance url 2025-08-01 09:45:25 +03:00
calli
7ce238c9c7 increase batch from 5 to 50 2025-05-17 20:28:01 +03:00
calli
6523000e69 lets batch the requests for users with gazillion characters 2025-05-17 19:51:21 +03:00
calli
b993b28840 Add balance and storage alert counts to account card 2025-05-17 17:21:49 +03:00
calli
c036bc10e1 Sort storages 2025-05-17 17:21:37 +03:00
calli
b743193f46 update discord link 2025-05-17 17:10:58 +03:00
calli
02ebaf6e35 remove unused imports 2025-05-02 23:00:56 +03:00
calli
3a0843e54c make keys unique for the new tooltip 2025-05-02 22:00:40 +03:00
calli
e43bd91bef make active filters more visible 2025-05-02 21:54:22 +03:00
18 changed files with 2653 additions and 131 deletions

View File

@@ -4,4 +4,5 @@ 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)
NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no
SENTRY_AUTH_TOKEN=Sentry token for error reporting.
LOG_LEVEL=warn
LOG_LEVEL=warn
WEBHOOK_URL=Webhook URL

View File

@@ -2,9 +2,9 @@
Simple tool to track your PI planet extractors. Login with your characters and enjoy the PI!
Any questions, feedback or suggestions are welcome at [EVE PI Discord](https://discord.gg/GPtw5kfuJu)
Any questions, feedback or suggestions are welcome at [EVE PI Discord](https://discord.gg/bCdXzU8PHK)
## [Avanto hosted PI tool](https://pi.avanto.tk)
## [Hosted PI tool](https://pi.calli.fi)
![Screenshot of PI tool](https://github.com/calli-eve/eve-pi/blob/main/images/eve-pi.png)
![3D render of a planet](https://github.com/calli-eve/eve-pi/blob/main/images/3dplanet.png)
@@ -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,12 @@ 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)
# Zulip Configuration (optional)
ZULIP_URL=https://zulip.site.quack-lab.dev/api/v1/messages
ZULIP_EMAIL=evepi-bot@zulip.site.quack-lab.dev
ZULIP_API_KEY=9LlHp6flAGWF0x5KZnyMJTM3qm4CiHkn
ZULIP_STREAM=EvePI
```
## Run locally
@@ -72,6 +79,58 @@ 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
### Webhook Configuration
To enable webhook notifications:
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL or other webhook endpoint
2. Open the Settings dialog in the application and enable webhook notifications
3. Configure thresholds in the Settings dialog:
- **Extractor Expiry Warning**: How early to warn about extractor expiry (ISO 8601 duration format, default: P12H = 12 hours)
- **Storage Warning Threshold**: Storage fill percentage for warnings (default: 85%)
- **Storage Critical Threshold**: Storage fill percentage for critical alerts (default: 100%)
### Webhook Behavior
- **Automatic checks**: Runs every minute to check extractor expiry and storage levels
- **Data fetch checks**: Also checks when planet data is refreshed
- **Duplicate prevention**: Won't send the same alert repeatedly within cooldown periods
- **State reset**: Extractor alerts reset when extractors are restarted with fresh cycles
### Webhook Payload Format
The webhook sends JSON payloads with this structure:
```json
{
"type": "extractor_expiring|extractor_expired|storage_almost_full|storage_full|launchpad_almost_full|launchpad_full",
"message": "Human readable message",
"characterName": "Character Name",
"planetName": "Planet Name",
"details": {
"extractorType": "Product Type",
"hoursRemaining": 1.5,
"storageUsed": 8500,
"storageCapacity": 10000,
"fillPercentage": 85.0
},
"timestamp": "2024-01-01T12:00:00.000Z"
}
```
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

1537
bun.lock Normal file

File diff suppressed because it is too large Load Diff

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

14
run.conf Normal file
View File

@@ -0,0 +1,14 @@
# tmux-daemon.conf
# Configuration file for tmux-daemon.sh
# Session name
SESSION="eve-pi"
# Whether to attach to session after setup (0 or 1)
ATTACH_SESSION=0
# Commands to run (array format)
# Format: "workdir:::command" OR just "command"
CMDS=(
"bun dev"
)

141
run.sh Normal file
View File

@@ -0,0 +1,141 @@
#!/bin/bash
# tmux-daemon.sh
# Idempotent tmux daemon manager with config file support
# Load config file if it exists
SCRIPT_DIR=$(dirname "$0")
SCRIPT_NAME=$(basename "$0" .sh)
CONFIG_FILE="${SCRIPT_DIR}/${SCRIPT_NAME}.conf"
if [ -f "$CONFIG_FILE" ]; then
echo "Loading config from $CONFIG_FILE"
source "$CONFIG_FILE"
else
echo "Config file $CONFIG_FILE not found, generating template..."
cat > "$CONFIG_FILE" << 'EOF'
# tmux-daemon.conf
# Configuration file for tmux-daemon.sh
# Session name
SESSION="example"
# Whether to attach to session after setup (0 or 1)
ATTACH_SESSION=0
# Commands to run (array format)
# Format: "workdir:::command" OR just "command"
CMDS=(
"ping google.com"
"ping google.com"
"ping google.com"
"ping google.com"
"ping google.com"
"ping google.com"
"ping google.com"
)
EOF
echo "Generated $CONFIG_FILE with default values. Please edit and run again."
exit 0
fi
# Validate required variables
if [ -z "$SESSION" ]; then
echo "Error: SESSION must be set in $CONFIG_FILE" >&2
exit 1
fi
if [ ${#CMDS[@]} -eq 0 ]; then
echo "Error: CMDS array must be set in $CONFIG_FILE" >&2
exit 1
fi
echo "[main] SESSION: $SESSION"
echo "[main] Commands: ${CMDS[*]}"
# Create session if missing
if ! tmux has-session -t $SESSION 2>/dev/null; then
echo "[main] Creating tmux session: $SESSION"
tmux new-session -d -s $SESSION -n win0
else
echo "[main] Session $SESSION exists, reusing..."
fi
win=0
pane=0
for i in "${!CMDS[@]}"; do
entry="${CMDS[$i]}"
echo "[loop:$i] entry: $entry"
if [[ "$entry" == *":::"* ]]; then
workdir="${entry%%:::*}"
echo "[loop:$i] workdir set from entry: $workdir"
cmd="${entry#*:::}"
echo "[loop:$i] cmd set from entry: $cmd"
else
workdir="$PWD"
echo "[loop:$i] workdir set to PWD: $workdir"
cmd="$entry"
echo "[loop:$i] cmd set to entry: $cmd"
fi
echo "[loop:$i] Processing command: $cmd (workdir: $workdir)"
# Determine target window/pane
win=$((i / 4))
echo "[loop:$i] win updated: $win"
pane=$((i % 4))
echo "[loop:$i] pane updated: $pane"
# Create window if missing
if ! tmux list-windows -t $SESSION | grep -q "^$win:"; then
echo "[loop:$i] Creating window $win"
tmux new-window -t $SESSION:$win -n win$win
fi
# Get current pane count in window
panes=($(tmux list-panes -t $SESSION:$win -F '#P'))
echo "[loop:$i] panes in window $win: ${panes[*]}"
# Create missing pane if needed
if [ $pane -ge ${#panes[@]} ]; then
echo "[loop:$i] Splitting pane $((pane-1)) to create pane $pane in window $win"
if [ $pane -eq 1 ]; then
tmux split-window -h -t $SESSION:$win
elif [ $pane -eq 2 ]; then
tmux split-window -v -t $SESSION:$win
elif [ $pane -eq 3 ]; then
tmux split-window -v -t $SESSION:$win.1
fi
tmux select-layout -t $SESSION:$win tiled >/dev/null
fi
# Check if command is already running in the pane
running=$(tmux list-panes -t $SESSION:$win -F '#{pane_current_command}' | sed -n "$((pane+1))p")
echo "[loop:$i] running in pane $pane: $running"
if [[ "$running" == "$(basename "$cmd" | awk '{print $1}')" ]]; then
echo "[loop:$i] Command already running in pane $pane, skipping..."
continue
fi
# Create temporary script to run command in auto-restart loop
tmpfile=$(mktemp)
echo "[loop:$i] tmpfile created: $tmpfile"
cat >"$tmpfile" <<EOF
#!/bin/bash
cd "$workdir" || exit 1
while true; do
echo '[tmux-daemon] Starting: $cmd (in $workdir)'
$cmd
echo '[tmux-daemon] Command exited with code \$?'
sleep 2
done
EOF
chmod +x "$tmpfile"
echo "[loop:$i] Sending command to pane $pane in window $win"
tmux send-keys -t $SESSION:$win.$pane "$tmpfile" C-m
done
echo "[main] Done."
if [ $ATTACH_SESSION -eq 1 ]; then
echo "[main] Attaching to tmux session: $SESSION"
tmux attach -t $SESSION
fi

View File

@@ -376,6 +376,24 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
>
Extractors: {runningExtractors}/{totalExtractors}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: Object.values(planetDetails).some(d => d.alertState.hasLowStorage) ? theme.palette.error.main : theme.palette.text.secondary,
}}
>
Storage Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLowStorage).length}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: Object.values(planetDetails).some(d => d.alertState.hasLargeExtractorDifference) ? theme.palette.error.main : theme.palette.text.secondary,
}}
>
Balance Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLargeExtractorDifference).length}
</Typography>
</Box>
</Box>
<IconButton

View File

@@ -4,7 +4,7 @@ export const DiscordButton = () => {
<Box>
<Tooltip title="Come nerd out in discord about PI and this tool">
<Button
href="https://discord.gg/GPtw5kfuJu"
href="https://discord.gg/bCdXzU8PHK"
target="_blank"
style={{ width: "100%" }}
sx={{ color: "white", display: "block" }}

View File

@@ -48,7 +48,7 @@ declare module "@mui/material/styles" {
}
export const MainGrid = () => {
const { characters, updateCharacter } = useContext(CharacterContext);
const { characters } = useContext(CharacterContext);
const { compactMode, toggleCompactMode, alertMode, toggleAlertMode, planMode, togglePlanMode, extractionTimeMode, toggleExtractionTimeMode } = useContext(SessionContext);
const [accountOrder, setAccountOrder] = useState<string[]>([]);
const [allCollapsed, setAllCollapsed] = useState(false);
@@ -170,7 +170,7 @@ export const MainGrid = () => {
size="small"
style={{
backgroundColor: compactMode
? "rgba(144, 202, 249, 0.08)"
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleCompactMode}
@@ -183,7 +183,7 @@ export const MainGrid = () => {
size="small"
style={{
backgroundColor: alertMode
? "rgba(144, 202, 249, 0.08)"
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleAlertMode}
@@ -196,7 +196,7 @@ export const MainGrid = () => {
size="small"
style={{
backgroundColor: planMode
? "rgba(144, 202, 249, 0.08)"
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={togglePlanMode}
@@ -209,7 +209,7 @@ export const MainGrid = () => {
size="small"
style={{
backgroundColor: extractionTimeMode
? "rgba(144, 202, 249, 0.08)"
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleExtractionTimeMode}

View File

@@ -1,6 +1,5 @@
import { ColorContext, SessionContext } from "@/app/context/Context";
import { PI_TYPES_MAP, STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES, EVE_IMAGE_URL, PI_SCHEMATICS, LAUNCHPAD_IDS } from "@/const";
import { planetCalculations } from "@/planets";
import { PI_TYPES_MAP, EVE_IMAGE_URL, LAUNCHPAD_IDS } from "@/const";
import { AccessToken, PlanetWithInfo } from "@/types";
import { PlanetCalculations, StorageInfo } from "@/types/planet";
import CloseIcon from "@mui/icons-material/Close";
@@ -419,50 +418,55 @@ export const PlanetTableRow = ({
</TableRow>
</TableHead>
<TableBody>
{planetDetails.storageInfo.map((storage: StorageInfo) => {
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
const contents = planet.info.pins.find(p => p.type_id === storage.type_id)?.contents || [];
return (
<React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}`}>
<TableRow>
<TableCell>{isLaunchpad ? 'Launchpad' : 'Storage'}</TableCell>
<TableCell align="right">{storage.capacity.toFixed(1)} m³</TableCell>
<TableCell align="right">{storage.used.toFixed(1)} m³</TableCell>
<TableCell align="right" sx={{ color }}>{fillRate.toFixed(1)}%</TableCell>
<TableCell align="right">
{storage.value > 0 ? (
storage.value >= 1000000000
? `${(storage.value / 1000000000).toFixed(2)} B`
: `${(storage.value / 1000000).toFixed(0)} M`
) : '-'} ISK
</TableCell>
</TableRow>
{contents.length > 0 && (
{planetDetails.storageInfo
.map(storage => ({
...storage,
isLaunchpad: LAUNCHPAD_IDS.includes(storage.type_id)
}))
.sort((a, b) => (b.isLaunchpad ? 1 : 0) - (a.isLaunchpad ? 1 : 0))
.map((storage, idx) => {
const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
const contents = planet.info.pins.find(p => p.type_id === storage.type_id)?.contents || [];
return (
<React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}>
<TableRow>
<TableCell colSpan={5} sx={{ pt: 0, pb: 0 }}>
<Table size="small">
<TableBody>
{contents.map(content => (
<TableRow key={content.type_id}>
<TableCell sx={{ pl: 2 }}>
{PI_TYPES_MAP[content.type_id]?.name}
</TableCell>
<TableCell align="right" colSpan={4}>
{content.amount.toFixed(1)} units
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TableCell>{storage.isLaunchpad ? 'Launchpad' : 'Storage'}</TableCell>
<TableCell align="right">{storage.capacity.toFixed(1)} m³</TableCell>
<TableCell align="right">{storage.used.toFixed(1)} m³</TableCell>
<TableCell align="right" sx={{ color }}>{fillRate.toFixed(1)}%</TableCell>
<TableCell align="right">
{storage.value > 0 ? (
storage.value >= 1000000000
? `${(storage.value / 1000000000).toFixed(2)} B`
: `${(storage.value / 1000000).toFixed(0)} M`
) : '-'} ISK
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{contents.length > 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ pt: 0, pb: 0 }}>
<Table size="small">
<TableBody>
{contents.map((content, idy) => (
<TableRow key={`content-${character.character.characterId}-${planet.planet_id}-${storage.type}-${content.type_id}-${idx}-${idy}`}>
<TableCell sx={{ pl: 2 }}>
{PI_TYPES_MAP[content.type_id]?.name}
</TableCell>
<TableCell align="right" colSpan={4}>
{content.amount.toFixed(1)} units
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</Box>
@@ -482,27 +486,32 @@ export const PlanetTableRow = ({
>
<div style={{ display: "flex", flexDirection: "column" }}>
{planetDetails.storageInfo.length === 0 &&<Typography fontSize={theme.custom.smallText}>No storage</Typography>}
{planetDetails.storageInfo.map((storage: StorageInfo) => {
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
return (
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}`} style={{ display: "flex", alignItems: "center" }}>
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}>
{isLaunchpad ? 'L' : 'S'}
</Typography>
<Typography fontSize={theme.custom.smallText} style={{ color }}>
{fillRate.toFixed(1)}%
</Typography>
{storage.value > 0 && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
({Math.round(storage.value / 1000000)}M)
{planetDetails.storageInfo
.map(storage => ({
...storage,
isLaunchpad: LAUNCHPAD_IDS.includes(storage.type_id)
}))
.sort((a, b) => (b.isLaunchpad ? 1 : 0) - (a.isLaunchpad ? 1 : 0))
.map((storage, idx) => {
const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
return (
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`} style={{ display: "flex", alignItems: "center" }}>
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}>
{storage.isLaunchpad ? 'L' : 'S'}
</Typography>
)}
</div>
);
})}
<Typography fontSize={theme.custom.smallText} style={{ color }}>
{fillRate.toFixed(1)}%
</Typography>
{storage.value > 0 && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
({Math.round(storage.value / 1000000)}M)
</Typography>
)}
</div>
);
})}
</div>
</Tooltip>
</TableCell>

View File

@@ -15,13 +15,14 @@ import {
Box,
FormControlLabel,
Checkbox,
Divider,
} 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, webhookServerEnabled } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -51,6 +52,57 @@ export const SettingsButton = () => {
setShowProductIcons(event.target.checked);
};
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!webhookServerEnabled && event.target.checked) {
// Don't allow enabling if server doesn't support it
return;
}
setWebhookConfig(prev => ({ ...prev, enabled: event.target.checked }));
};
const handleExpiryThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value }));
};
const handleStorageWarningChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value }));
}
};
const handleStorageCriticalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value }));
}
};
const handleRefreshStats = () => {
// Trigger a manual refresh of character data
window.location.reload();
};
const handleRefreshESI = async () => {
try {
const response = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true })
});
if (response.ok) {
console.log("✅ ESI data refreshed successfully");
// Optionally reload the page to show fresh data
window.location.reload();
} else {
console.error("❌ Failed to refresh ESI data");
}
} catch (error) {
console.error("❌ Error refreshing ESI data:", error);
}
};
return (
<Tooltip title="Toggle settings dialog">
<>
@@ -93,6 +145,165 @@ export const SettingsButton = () => {
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Data Refresh</Typography>
<Button
onClick={handleRefreshESI}
variant="contained"
fullWidth
sx={{ mt: 1 }}
>
Refresh ESI Data
</Button>
<Button
onClick={handleRefreshStats}
variant="outlined"
fullWidth
sx={{ mt: 1 }}
>
Refresh All Data
</Button>
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Webhook Notifications</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Configure Zulip alerts for extractor expiry and storage capacity warnings.
</Typography>
<FormControlLabel
control={
<Checkbox
checked={webhookConfig.enabled}
onChange={handleWebhookEnabledChange}
/>
}
label="Enable webhook notifications"
/>
{webhookConfig.enabled && (
<>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>Zulip Configuration</Typography>
<TextField
label="Zulip URL"
value={webhookConfig.zulipUrl || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipUrl: e.target.value }))}
fullWidth
margin="normal"
placeholder="https://zulip.site.quack-lab.dev/api/v1/messages"
helperText="Zulip API endpoint URL"
/>
<TextField
label="Zulip Email"
value={webhookConfig.zulipEmail || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipEmail: e.target.value }))}
fullWidth
margin="normal"
placeholder="evepi-bot@zulip.site.quack-lab.dev"
helperText="Bot email for authentication"
/>
<TextField
label="Zulip API Key"
type="password"
value={webhookConfig.zulipApiKey || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipApiKey: e.target.value }))}
fullWidth
margin="normal"
placeholder="9LlHp6flAGWF0x5KZnyMJTM3qm4CiHkn"
helperText="API key for authentication"
/>
<TextField
label="Zulip Stream"
value={webhookConfig.zulipStream || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipStream: e.target.value }))}
fullWidth
margin="normal"
placeholder="EvePI"
helperText="Stream name for messages"
/>
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>Alert Thresholds</Typography>
<TextField
label="Extractor Expiry Warning"
value={webhookConfig.expiryThreshold}
onChange={handleExpiryThresholdChange}
fullWidth
margin="normal"
helperText="ISO 8601 duration format (e.g., P12H for 12 hours, P1D for 1 day)"
/>
<TextField
type="number"
label="Storage Warning Threshold (%)"
value={webhookConfig.storageWarningThreshold}
onChange={handleStorageWarningChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Alert when storage reaches this percentage (0-100)"
/>
<TextField
type="number"
label="Storage Critical Threshold (%)"
value={webhookConfig.storageCriticalThreshold}
onChange={handleStorageCriticalChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Alert when storage is completely full (usually 100)"
/>
<Button
onClick={async () => {
try {
const response = await fetch("/api/zulip-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: 'test',
message: 'Test webhook from EvePI',
characterName: 'Test Character',
planetName: 'Test Planet',
timestamp: new Date().toISOString(),
zulipUrl: webhookConfig.zulipUrl,
zulipEmail: webhookConfig.zulipEmail,
zulipApiKey: webhookConfig.zulipApiKey,
zulipStream: webhookConfig.zulipStream
})
});
if (response.ok) {
alert("✅ Test webhook sent successfully!");
} else {
const error = await response.text();
alert(`❌ Test webhook failed: ${error}`);
}
} catch (error) {
alert(`❌ Error testing webhook: ${error}`);
}
}}
variant="outlined"
sx={{ mt: 2 }}
fullWidth
>
Test Webhook
</Button>
</>
)}
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Colors</Typography>
</Box>
{Object.keys(colors).map((key) => {
return (
<div key={`color-row-${key}`}>

View File

@@ -1,5 +1,6 @@
import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { WebhookConfig } from "@/types/webhook";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
@@ -41,6 +42,9 @@ export const SessionContext = createContext<{
setBalanceThreshold: Dispatch<SetStateAction<number>>;
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
webhookConfig: WebhookConfig;
setWebhookConfig: Dispatch<SetStateAction<WebhookConfig>>;
webhookServerEnabled: boolean;
}>({
sessionReady: false,
refreshSession: () => {},
@@ -70,6 +74,14 @@ export const SessionContext = createContext<{
setBalanceThreshold: () => {},
showProductIcons: false,
setShowProductIcons: () => {},
webhookConfig: {
enabled: false,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
},
setWebhookConfig: () => {},
webhookServerEnabled: false,
});
export type ColorSelectionType = {

View File

@@ -18,6 +18,122 @@ import { useSearchParams } from "next/navigation";
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
import { PlanetConfig } from "@/types";
// Webhook service removed - using direct API calls
import { WebhookConfig } from "@/types/webhook";
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
import { planetCalculations } from "@/planets";
// Webhook check functions
const checkExtractorExpiry = async (character: any, planet: any, extractors: any[], config: WebhookConfig) => {
if (!config.enabled || !config.zulipUrl) return;
const now = new Date();
const expiryThreshold = new Date(now.getTime() + 12 * 60 * 60 * 1000); // 12 hours from now
for (const extractor of extractors) {
if (extractor.expiry_time) {
const expiryTime = new Date(extractor.expiry_time);
if (expiryTime <= expiryThreshold) {
const hoursRemaining = Math.round((expiryTime.getTime() - now.getTime()) / (1000 * 60 * 60));
const message = `⚠️ Extractor ${extractor.type_name} expires in ${hoursRemaining} hours`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'extractor_expiring',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: now.toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
}
}
}
};
const checkStorageCapacity = async (character: any, planet: any, pins: any[], config: WebhookConfig) => {
if (!config.enabled || !config.zulipUrl) return;
// Import the storage constants
const { STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } = await import('@/const');
// Find storage facilities using the same logic as AccountCard
const storageFacilities = pins.filter((pin: any) =>
STORAGE_IDS().some((storage: any) => storage.type_id === pin.type_id)
);
for (const storage of storageFacilities) {
const storageType = STORAGE_IDS().find((s: any) => s.type_id === storage.type_id)?.name;
const storageCapacity = STORAGE_CAPACITIES[storage.type_id];
const totalVolume = storage.contents
.reduce((sum: number, item: any) => {
const volume = PI_PRODUCT_VOLUMES[item.type_id];
return sum + (item.amount * volume);
}, 0);
const fillPercentage = (totalVolume / storageCapacity) * 100;
if (fillPercentage >= config.storageCriticalThreshold) {
const message = `🚨 Storage ${storageType} is ${fillPercentage.toFixed(1)}% full!`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'storage_full',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: new Date().toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
} else if (fillPercentage >= config.storageWarningThreshold) {
const message = `⚠️ Storage ${storageType} is ${fillPercentage.toFixed(1)}% full`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'storage_almost_full',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: new Date().toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
}
}
};
// Add batch processing utility
const processInBatches = async <T, R>(
items: T[],
batchSize: number,
processFn: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processFn));
results.push(...batchResults);
}
return results;
};
const Home = () => {
const searchParams = useSearchParams();
@@ -32,6 +148,21 @@ const Home = () => {
const [balanceThreshold, setBalanceThreshold] = useState(1000);
const [showProductIcons, setShowProductIcons] = useState(false);
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem("webhookConfig");
if (stored) {
return JSON.parse(stored);
}
}
return {
enabled: false,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
};
});
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false);
@@ -63,15 +194,13 @@ const Home = () => {
};
const refreshSession = async (characters: AccessToken[]) => {
return Promise.all(
characters.map((c) => {
try {
return refreshToken(c);
} catch {
return { ...c, needsLogin: true };
}
}),
);
return processInBatches(characters, 50, async (c) => {
try {
return await refreshToken(c);
} catch {
return { ...c, needsLogin: true };
}
});
};
const handleCallback = async (
@@ -96,6 +225,7 @@ const Home = () => {
};
const initializeCharacters = useCallback((): AccessToken[] => {
if (typeof window === 'undefined') return [];
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
@@ -107,27 +237,44 @@ const Home = () => {
const initializeCharacterPlanets = (
characters: AccessToken[],
): Promise<AccessToken[]> =>
Promise.all(
characters.map(async (c) => {
if (c.needsLogin || c.character === undefined)
return { ...c, planets: [] };
const planets = await getPlanets(c);
const planetsWithInfo: PlanetWithInfo[] = await Promise.all(
planets.map(async (p) => ({
...p,
info: await getPlanet(c, p),
infoUniverse: await getPlanetUniverse(p),
})),
processInBatches(characters, 50, async (c) => {
if (c.needsLogin || c.character === undefined)
return { ...c, planets: [] };
const planets = await getPlanets(c);
const planetsWithInfo: PlanetWithInfo[] = await processInBatches(
planets,
3,
async (p) => ({
...p,
info: await getPlanet(c, p),
infoUniverse: await getPlanetUniverse(p),
})
);
// Run webhook checks for each planet if webhooks are enabled
if (webhookConfig.enabled) {
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
for (const planet of planetsWithInfo) {
try {
const calculations = planetCalculations(planet);
await checkExtractorExpiry(c, planet, calculations.extractors, webhookConfig);
await checkStorageCapacity(c, planet, planet.info.pins, webhookConfig);
} catch (error) {
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
}
}
}
return {
...c,
planets: planetsWithInfo,
};
}),
);
...c,
planets: planetsWithInfo,
};
});
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
localStorage.setItem("characters", JSON.stringify(characters));
if (typeof window !== 'undefined') {
localStorage.setItem("characters", JSON.stringify(characters));
}
return characters;
};
@@ -189,65 +336,123 @@ const Home = () => {
};
useEffect(() => {
const storedCompactMode = localStorage.getItem("compactMode");
if (!storedCompactMode) return;
storedCompactMode === "true" ? setCompactMode(true) : false;
}, []);
useEffect(() => {
const storedColors = localStorage.getItem("colors");
if (!storedColors) return;
setColors(JSON.parse(storedColors));
}, []);
useEffect(() => {
const storedAlertMode = localStorage.getItem("alertMode");
if (!storedAlertMode) return;
setAlertMode(JSON.parse(storedAlertMode));
}, []);
useEffect(() => {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
if (typeof window !== 'undefined') {
const storedCompactMode = localStorage.getItem("compactMode");
if (storedCompactMode) {
setCompactMode(storedCompactMode === "true");
}
}
}, []);
useEffect(() => {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
if (typeof window !== 'undefined') {
const storedColors = localStorage.getItem("colors");
if (storedColors) {
setColors(JSON.parse(storedColors));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedAlertMode = localStorage.getItem("alertMode");
if (storedAlertMode) {
setAlertMode(JSON.parse(storedAlertMode));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
}
}, [balanceThreshold]);
useEffect(() => {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
if (typeof window !== 'undefined') {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
}
}, [compactMode]);
useEffect(() => {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
if (typeof window !== 'undefined') {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
}
}, [alertMode]);
useEffect(() => {
localStorage.setItem("colors", JSON.stringify(colors));
if (typeof window !== 'undefined') {
localStorage.setItem("colors", JSON.stringify(colors));
}
}, [colors]);
useEffect(() => {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
if (typeof window !== 'undefined') {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
}
}
}, []);
useEffect(() => {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
if (typeof window !== 'undefined') {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}
}, [extractionTimeMode]);
// Load webhook config from localStorage after hydration
useEffect(() => {
if (typeof window !== 'undefined') {
const savedWebhookConfig = localStorage.getItem("webhookConfig");
if (savedWebhookConfig) {
try {
const config = JSON.parse(savedWebhookConfig);
setWebhookConfig(prev => ({ ...prev, ...config }));
} catch (error) {
console.warn('Failed to parse webhook config from localStorage:', error);
}
}
}
}, []);
// Save webhook config to localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
}
}, [webhookConfig]);
useEffect(() => {
console.log('🔧 Initializing app...');
fetch("api/env")
.then((r) => r.json())
.then((env) => {
console.log('🌐 Environment loaded:', env);
setEnvironment({
EVE_SSO_CLIENT_ID: env.EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL: env.EVE_SSO_CALLBACK_URL,
});
setWebhookServerEnabled(env.WEBHOOK_ENABLED || false);
console.log('🔔 Webhook server enabled:', env.WEBHOOK_ENABLED);
// Only allow enabling webhooks if server supports it
if (!env.WEBHOOK_ENABLED && webhookConfig.enabled) {
console.log('⚠️ Disabling webhooks - server not configured');
setWebhookConfig(prev => ({
...prev,
enabled: false
}));
}
})
.then(initializeCharacters)
.then(refreshSession)
@@ -276,6 +481,37 @@ const Home = () => {
return () => clearInterval(interval);
});
// Regular webhook checks (every minute)
useEffect(() => {
if (!webhookConfig.enabled) {
console.log('🔕 Webhooks disabled, skipping regular checks');
return;
}
console.log('⏰ Setting up regular webhook checks (every minute)');
const runRegularWebhookChecks = async () => {
console.log('🔄 Running regular webhook checks...');
const currentCharacters = initializeCharacters();
for (const character of currentCharacters) {
if (character.needsLogin || !character.planets) continue;
// Webhook checks removed
}
// Cleanup old webhook states
cleanupOldWebhookStates();
};
// Run immediately
runRegularWebhookChecks();
// Set up interval for every minute
const webhookInterval = setInterval(runRegularWebhookChecks, 60000);
return () => clearInterval(webhookInterval);
}, [webhookConfig.enabled]);
return (
<SessionContext.Provider
value={{
@@ -299,6 +535,9 @@ const Home = () => {
setBalanceThreshold,
showProductIcons,
setShowProductIcons,
webhookConfig,
setWebhookConfig,
webhookServerEnabled,
}}
>
<CharacterContext.Provider

View File

@@ -10,6 +10,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
const WEBHOOK_ENABLED = !!process.env.WEBHOOK_URL;
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
logger.error({
@@ -27,11 +28,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
event: 'env_request_success',
vars: {
hasCallbackUrl: true,
hasClientId: true
hasClientId: true,
webhookEnabled: WEBHOOK_ENABLED
}
});
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
return res.json({
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL,
WEBHOOK_ENABLED
});
} catch (e) {
logger.error({
event: 'env_request_failed',

View File

@@ -0,0 +1,122 @@
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const payload = req.body;
// Check if this is a test request
if (payload.type === 'test') {
const missing = [];
if (!payload.zulipUrl) missing.push('zulipUrl');
if (!payload.zulipEmail) missing.push('zulipEmail');
if (!payload.zulipApiKey) missing.push('zulipApiKey');
if (!payload.zulipStream) missing.push('zulipStream');
if (missing.length > 0) {
return res.status(400).json({ error: `Missing required Zulip configuration: ${missing.join(', ')}` });
}
const zulipUrl = payload.zulipUrl;
const zulipEmail = payload.zulipEmail;
const zulipApiKey = payload.zulipApiKey;
const zulipStream = payload.zulipStream;
const topic = 'Test-Webhook-Test';
const content = '🧪 **Test Webhook**\nThis is a test message from EvePI to verify Zulip integration is working correctly.\n\n**Time:** ' + new Date().toLocaleString();
const zulipPayload = new URLSearchParams();
zulipPayload.append('type', 'stream');
zulipPayload.append('to', zulipStream);
zulipPayload.append('topic', topic);
zulipPayload.append('content', content);
const auth = Buffer.from(`${zulipEmail}:${zulipApiKey}`).toString('base64');
const response = await fetch(zulipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`
},
body: zulipPayload.toString()
});
if (!response.ok) {
const errorText = await response.text();
return res.status(400).json({
error: 'Zulip test webhook failed',
details: errorText,
status: response.status
});
}
const result = await response.text();
return res.status(200).json({
success: true,
message: 'Test webhook sent successfully to Zulip',
result
});
}
// Validate payload for normal webhook
if (!payload.type || !payload.message || !payload.characterName || !payload.planetName) {
return res.status(400).json({ error: 'Invalid payload' });
}
const zulipUrl = payload.zulipUrl;
const zulipEmail = payload.zulipEmail;
const zulipApiKey = payload.zulipApiKey;
const zulipStream = payload.zulipStream;
if (!zulipUrl || !zulipEmail || !zulipApiKey || !zulipStream) {
return res.status(400).json({ error: 'Missing required Zulip configuration' });
}
// Create topic in format: CharacterName-PlanetName-ActionType
const actionType = payload.type.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
const topic = `${payload.characterName}-${payload.planetName}-${actionType}`;
// Format content with all the details
const content = `${payload.message}\n**Character:** ${payload.characterName}\n**Planet:** ${payload.planetName}\n**Time:** ${new Date(payload.timestamp).toLocaleString()}`;
// Create Zulip API payload
const zulipPayload = new URLSearchParams();
zulipPayload.append('type', 'stream');
zulipPayload.append('to', zulipStream);
zulipPayload.append('topic', topic);
zulipPayload.append('content', content);
console.log('🔔 Sending webhook to Zulip:', zulipUrl);
console.log('📤 Topic:', topic);
console.log('📤 Content:', content);
const response = await fetch(zulipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${zulipEmail}:${zulipApiKey}`).toString('base64')}`
},
body: zulipPayload.toString(),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Zulip webhook failed:', response.status, response.statusText, errorText);
throw new Error(`Zulip webhook failed with status ${response.status}: ${errorText}`);
}
console.log('✅ Webhook sent successfully to Zulip');
res.status(200).json({ success: true, message: 'Webhook sent to Zulip' });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
}
}

View File

@@ -78,6 +78,7 @@ export interface CharacterUpdate {
export interface Env {
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
WEBHOOK_ENABLED?: boolean;
}
export interface EvePraisalResult {

29
src/types/webhook.ts Normal file
View File

@@ -0,0 +1,29 @@
export interface WebhookConfig {
enabled: boolean;
expiryThreshold: string; // ISO 8601 duration format
storageWarningThreshold: number; // percentage
storageCriticalThreshold: number; // percentage
zulipUrl?: string;
zulipEmail?: string;
zulipApiKey?: string;
zulipStream?: string;
}
export interface WebhookPayload {
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full' | 'test';
message: string;
characterName: string;
planetName: string;
details?: {
extractorType?: string;
hoursRemaining?: number;
storageUsed?: number;
storageCapacity?: number;
fillPercentage?: number;
};
timestamp: string;
zulipUrl?: string;
zulipEmail?: string;
zulipApiKey?: string;
zulipStream?: string;
}

122
src/utils/webhookTracker.ts Normal file
View File

@@ -0,0 +1,122 @@
interface WebhookState {
sent: boolean;
lastSent: number;
}
// Create hash for unique events: character-planet-extractorEndTimestamp-event
export const createWebhookHash = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): string => {
return `${characterId}-${planetId}-${extractorEndTimestamp}-${event}`;
};
// Get webhook state from localStorage
const getWebhookState = (hash: string): WebhookState => {
if (typeof window === 'undefined') return { sent: false, lastSent: 0 };
try {
const stored = localStorage.getItem(`webhook_${hash}`);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.warn('Failed to parse webhook state from localStorage:', error);
}
return { sent: false, lastSent: 0 };
};
// Set webhook state in localStorage
const setWebhookState = (hash: string, state: WebhookState): void => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(`webhook_${hash}`, JSON.stringify(state));
} catch (error) {
console.warn('Failed to save webhook state to localStorage:', error);
}
};
export const shouldSendWebhook = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): boolean => {
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
const state = getWebhookState(hash);
return !state.sent;
};
// Storage webhook tracking with 1-hour cooldown
export const shouldSendStorageWebhook = (
characterId: number,
planetId: number,
storageTypeId: number,
webhookType: string
): boolean => {
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
const state = getWebhookState(hash);
if (!state.sent) {
return true;
}
// Check if 1 hour has passed since last notification
const oneHourAgo = Date.now() - (60 * 60 * 1000);
return state.lastSent < oneHourAgo;
};
export const markStorageWebhookSent = (
characterId: number,
planetId: number,
storageTypeId: number,
webhookType: string
): void => {
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
setWebhookState(hash, { sent: true, lastSent: Date.now() });
};
export const markWebhookSent = (
characterId: number,
planetId: number,
extractorEndTimestamp: string,
event: string
): void => {
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
setWebhookState(hash, { sent: true, lastSent: Date.now() });
};
// Cleanup old webhook states (older than 7 days)
export const cleanupOldWebhookStates = (): void => {
if (typeof window === 'undefined') return;
const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('webhook_')) {
const state = JSON.parse(localStorage.getItem(key) || '{}');
if (state.lastSent && state.lastSent < cutoff) {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
if (keysToRemove.length > 0) {
console.log(`🧹 Cleaned up ${keysToRemove.length} old webhook states`);
}
} catch (error) {
console.warn('Failed to cleanup old webhook states:', error);
}
};