Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@toanalien/ahha": "^0.1.1",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"confbox": "^0.2.4",
Expand Down
207 changes: 207 additions & 0 deletions src/app/(dashboard)/dashboard/profile/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export default function ProfilePage() {
const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" });
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);
const [webhookForm, setWebhookForm] = useState({
webhookEnabled: false,
webhookUrls: "",
webhookThrottleMs: 5,
webhookErrorCodes: [401, 403, 429, 500, 502, 503],
});
const [webhookStatus, setWebhookStatus] = useState({ type: "", message: "" });
const [webhookLoading, setWebhookLoading] = useState(false);
const [webhookTestLoading, setWebhookTestLoading] = useState(false);

useEffect(() => {
fetch("/api/settings")
Expand All @@ -35,6 +44,12 @@ export default function ProfilePage() {
outboundProxyUrl: data?.outboundProxyUrl || "",
outboundNoProxy: data?.outboundNoProxy || "",
});
setWebhookForm({
webhookEnabled: data?.webhookEnabled === true,
webhookUrls: (data?.webhookUrls || []).join("\n"),
webhookThrottleMs: Math.round((data?.webhookThrottleMs || 300000) / 60000),
webhookErrorCodes: data?.webhookErrorCodes || [401, 403, 429, 500, 502, 503],
});
setLoading(false);
})
.catch((err) => {
Expand Down Expand Up @@ -140,6 +155,96 @@ export default function ProfilePage() {
}
};

const updateWebhookEnabled = async (enabled) => {
setWebhookLoading(true);
setWebhookStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ webhookEnabled: enabled }),
});
const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setWebhookForm((prev) => ({ ...prev, webhookEnabled: enabled }));
setWebhookStatus({ type: "success", message: enabled ? "Webhook enabled" : "Webhook disabled" });
} else {
setWebhookStatus({ type: "error", message: data.error || "Failed to update" });
}
} catch {
setWebhookStatus({ type: "error", message: "An error occurred" });
} finally {
setWebhookLoading(false);
}
};

const saveWebhookSettings = async (e) => {
e.preventDefault();
setWebhookLoading(true);
setWebhookStatus({ type: "", message: "" });
try {
const urls = webhookForm.webhookUrls.split("\n").map(u => u.trim()).filter(Boolean);
const throttleMs = Math.max(1, webhookForm.webhookThrottleMs) * 60000;
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
webhookUrls: urls,
webhookThrottleMs: throttleMs,
webhookErrorCodes: webhookForm.webhookErrorCodes,
}),
});
const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setWebhookStatus({ type: "success", message: "Webhook settings saved" });
} else {
setWebhookStatus({ type: "error", message: data.error || "Failed to save" });
}
} catch {
setWebhookStatus({ type: "error", message: "An error occurred" });
} finally {
setWebhookLoading(false);
}
};

const testWebhook = async () => {
const urls = webhookForm.webhookUrls.split("\n").map(u => u.trim()).filter(Boolean);
if (urls.length === 0) {
setWebhookStatus({ type: "error", message: "Please enter at least one webhook URL" });
return;
}
setWebhookTestLoading(true);
setWebhookStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings/webhook-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
});
const data = await res.json();
if (data.success) {
setWebhookStatus({ type: "success", message: "Test notification sent successfully" });
} else {
setWebhookStatus({ type: "error", message: data.error || "Test failed" });
}
} catch {
setWebhookStatus({ type: "error", message: "An error occurred" });
} finally {
setWebhookTestLoading(false);
}
};

const toggleErrorCode = (code) => {
setWebhookForm((prev) => {
const codes = prev.webhookErrorCodes.includes(code)
? prev.webhookErrorCodes.filter(c => c !== code)
: [...prev.webhookErrorCodes, code];
return { ...prev, webhookErrorCodes: codes };
});
};

const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
Expand Down Expand Up @@ -650,6 +755,108 @@ export default function ProfilePage() {
</div>
</Card>

{/* Webhook Notifications */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-500">
<span className="material-symbols-outlined text-[20px]">notifications</span>
</div>
<h3 className="text-lg font-semibold">Notifications</h3>
</div>

<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Webhook Notifications</p>
<p className="text-sm text-text-muted">
Send alerts via webhook when provider errors occur (401, 429, etc.)
</p>
</div>
<Toggle
checked={webhookForm.webhookEnabled}
onChange={() => updateWebhookEnabled(!webhookForm.webhookEnabled)}
disabled={loading || webhookLoading}
/>
</div>

{webhookForm.webhookEnabled && (
<form onSubmit={saveWebhookSettings} className="flex flex-col gap-4 pt-2 border-t border-border/50">
<div className="flex flex-col gap-2">
<label className="font-medium">Webhook URLs</label>
<textarea
className="w-full px-3 py-2 rounded-lg border border-border bg-bg text-text-main text-sm font-mono min-h-[80px] resize-y focus:outline-none focus:ring-2 focus:ring-primary/50"
placeholder={"generic+https://example.com/webhook\nslack://hook:TOKEN@webhook\ndiscord://TOKEN@WEBHOOK_ID"}
value={webhookForm.webhookUrls}
onChange={(e) => setWebhookForm((prev) => ({ ...prev, webhookUrls: e.target.value }))}
disabled={loading || webhookLoading}
/>
<p className="text-sm text-text-muted">
One URL per line. Supports: Slack, Discord, Telegram, Webhook, Ntfy (powered by ahha)
</p>
</div>

<div className="flex flex-col gap-2 pt-2 border-t border-border/50">
<label className="font-medium">Throttle (minutes)</label>
<Input
type="number"
min="1"
max="60"
value={webhookForm.webhookThrottleMs}
onChange={(e) => setWebhookForm((prev) => ({ ...prev, webhookThrottleMs: parseInt(e.target.value) || 5 }))}
disabled={loading || webhookLoading}
className="w-24"
/>
<p className="text-sm text-text-muted">
Same error won&apos;t send duplicate notifications within this window
</p>
</div>

<div className="flex flex-col gap-2 pt-2 border-t border-border/50">
<label className="font-medium">Error Codes to Notify</label>
<div className="flex flex-wrap gap-2">
{[401, 403, 429, 500, 502, 503].map((code) => (
<button
key={code}
type="button"
onClick={() => toggleErrorCode(code)}
className={cn(
"px-3 py-1 rounded-md text-sm font-mono border transition-colors",
webhookForm.webhookErrorCodes.includes(code)
? "bg-primary/10 border-primary/30 text-primary"
: "bg-bg border-border text-text-muted hover:border-primary/30"
)}
>
{code}
</button>
))}
</div>
</div>

<div className="pt-2 border-t border-border/50 flex items-center gap-2">
<Button
type="button"
variant="secondary"
loading={webhookTestLoading}
disabled={loading || webhookLoading}
onClick={testWebhook}
>
Test Webhook
</Button>
<Button type="submit" variant="primary" loading={webhookLoading}>
Save
</Button>
</div>
</form>
)}

{webhookStatus.message && (
<p className={`text-sm ${webhookStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}>
{webhookStatus.message}
</p>
)}
</div>
</Card>

{/* App Info */}
<div className="text-center text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import PropTypes from "prop-types";
import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal } from "@/shared/components";
import ErrorHistoryModal from "./ErrorHistoryModal";

// ── CooldownTimer ──────────────────────────────────────────────
function CooldownTimer({ until }) {
Expand All @@ -29,7 +30,7 @@ function CooldownTimer({ until }) {
CooldownTimer.propTypes = { until: PropTypes.string.isRequired };

// ── ConnectionRow ──────────────────────────────────────────────
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) {
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, onShowErrorHistory }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const [isCooldown, setIsCooldown] = useState(false);
Expand Down Expand Up @@ -159,6 +160,10 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov
)}
</div>
)}
<button onClick={onShowErrorHistory} className="flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-orange-500" title="Error History">
<span className="material-symbols-outlined text-[18px]">error_outline</span>
<span className="text-[10px] leading-tight">Errors</span>
</button>
<button onClick={onEdit} className="flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary">
<span className="material-symbols-outlined text-[18px]">edit</span>
<span className="text-[10px] leading-tight">Edit</span>
Expand Down Expand Up @@ -195,6 +200,7 @@ ConnectionRow.propTypes = {
onUpdateProxy: PropTypes.func,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onShowErrorHistory: PropTypes.func.isRequired,
};

// ── AddApiKeyModal ─────────────────────────────────────────────
Expand Down Expand Up @@ -306,6 +312,8 @@ export default function ConnectionsCard({ providerId, isOAuth }) {
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [showErrorHistoryModal, setShowErrorHistoryModal] = useState(false);
const [errorHistoryConnection, setErrorHistoryConnection] = useState(null);
const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState("1");

Expand Down Expand Up @@ -446,6 +454,7 @@ export default function ConnectionsCard({ providerId, isOAuth }) {
onUpdateProxy={(poolId) => handleUpdateProxy(conn.id, poolId)}
onEdit={() => { setSelectedConnection(conn); setShowEditModal(true); }}
onDelete={() => handleDelete(conn.id)}
onShowErrorHistory={() => { setErrorHistoryConnection(conn); setShowErrorHistoryModal(true); }}
/>
))}
</div>
Expand All @@ -470,6 +479,12 @@ export default function ConnectionsCard({ providerId, isOAuth }) {
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
<ErrorHistoryModal
isOpen={showErrorHistoryModal}
connectionId={errorHistoryConnection?.id}
connectionName={errorHistoryConnection?.displayName || errorHistoryConnection?.name || errorHistoryConnection?.email || errorHistoryConnection?.id}
onClose={() => setShowErrorHistoryModal(false)}
/>
</>
);
}
Expand Down
Loading