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
26 changes: 26 additions & 0 deletions django-backend/soroscan/ingest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,32 @@ def recent_errors(self, info: Info, limit: int = 10) -> list[ErrorLog]:

@strawberry.type
class Mutation:
@strawberry.mutation
def test_webhook_url(self, url: str, timeout_seconds: int = 10) -> dict:
"""Test a webhook URL by sending a ping payload."""
import requests
import json
from django.utils import timezone

payload = {"type": "ping", "timestamp": timezone.now().isoformat()}
payload_bytes = json.dumps(payload, sort_keys=True).encode("utf-8")
headers = {
"Content-Type": "application/json",
"X-SoroScan-Event": "ping",
}

try:
response = requests.post(
url,
data=payload_bytes,
headers=headers,
timeout=timeout_seconds,
)
success = response.status_code == 200
return {"success": success, "status_code": response.status_code}
except requests.RequestException as exc:
return {"success": False, "error": str(exc)}

@strawberry.mutation
def register_contract(
self,
Expand Down
2 changes: 1 addition & 1 deletion soroscan-frontend/__tests__/webhook-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe("CreateWebhookModal", () => {
const input = screen.getByPlaceholderText(/https:\/\/yourapp.io/)
fireEvent.change(input, { target: { value: "not-a-url" } })
fireEvent.blur(input)
expect(screen.getByText(/valid https:\/\//)).toBeInTheDocument()
expect(screen.getByText(/Must be a valid HTTPS URL/)).toBeInTheDocument()
})

it("shows no error for a valid URL", () => {
Expand Down
93 changes: 79 additions & 14 deletions soroscan-frontend/app/settings/components/WebhookManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ type Webhook = {

const defaultWebhooks: Webhook[] = [];

function isValidUrl(str: string): boolean {
try {
const u = new URL(str);
return u.protocol === "https:";
} catch {
return false;
}
}

export default function WebhookManager() {
const [webhooks, setWebhooks] = useState<Webhook[]>(() => {
if (typeof window !== "undefined") {
Expand All @@ -27,15 +36,51 @@ export default function WebhookManager() {
const [newUrl, setNewUrl] = useState("");
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; status_code?: number; error?: string } | null>(null);
const [urlTouched, setUrlTouched] = useState(false);

const urlValid = isValidUrl(newUrl);
const urlError = (urlTouched || newUrl.length > 0) && !urlValid ? (newUrl ? "Must be a valid HTTPS URL" : "URL is required") : null;

const persist = (next: Webhook[]) => {
setWebhooks(next);
localStorage.setItem("webhooks", JSON.stringify(next));
};

const handleTestConnection = async () => {
if (!isValidUrl(newUrl)) return;
setTesting(true);
setTestResult(null);
try {
const response = await fetch("/api/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `
mutation TestWebhookUrl($url: String!, $timeoutSeconds: Int!) {
testWebhookUrl(url: $url, timeoutSeconds: $timeoutSeconds)
}
`,
variables: { url: newUrl, timeoutSeconds: 10 },
}),
});
const result = await response.json();
setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" });
} catch {
setTestResult({ success: false, error: "Failed to test connection" });
} finally {
setTesting(false);
}
};

const handleAddWebhook = () => {
if (!newUrl.trim()) {
setError("Enter a valid webhook URL.");
setError("Enter a valid HTTPS URL.");
return;
}
if (!isValidUrl(newUrl)) {
setError("URL must be HTTPS.");
return;
}
const nextWebhook: Webhook = {
Expand Down Expand Up @@ -71,19 +116,39 @@ export default function WebhookManager() {
<div className="border border-green-500/30 rounded-xl p-5 bg-[#08102a]/80">
<h2 className="text-green-400 text-sm font-mono mb-3">[ WEBHOOKS ]</h2>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] items-center">
<input
value={newUrl}
onChange={(event) => setNewUrl(event.target.value)}
placeholder="https://hooks.example.com/events"
className="w-full rounded-lg border border-green-500/30 bg-transparent px-3 py-2 font-mono text-sm text-green-300 focus:outline-none focus:border-green-400"
/>
<button
onClick={handleAddWebhook}
className="rounded-lg border border-green-400 px-4 py-2 text-sm font-mono text-green-400 hover:bg-green-400/10 transition-colors"
>
+ Add Webhook
</button>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto] items-center">
<input
value={newUrl}
onChange={(event) => setNewUrl(event.target.value)}
onBlur={() => setUrlTouched(true)}
placeholder="https://hooks.example.com/events"
className="w-full rounded-lg border border-green-500/30 bg-transparent px-3 py-2 font-mono text-sm text-green-300 focus:outline-none focus:border-green-400"
/>
<button
onClick={handleTestConnection}
disabled={testing || !isValidUrl(newUrl)}
className="rounded-lg border border-blue-400 px-3 py-2 text-sm font-mono text-blue-400 hover:bg-blue-400/10 transition-colors disabled:opacity-50"
>
{testing ? "Testing..." : "Test"}
</button>
<button
onClick={handleAddWebhook}
className="rounded-lg border border-green-400 px-4 py-2 text-sm font-mono text-green-400 hover:bg-green-400/10 transition-colors"
>
+ Add Webhook
</button>
</div>
{urlError && <p className="text-red-400 text-sm">{urlError}</p>}
{testResult && (
<p className={`text-sm ${testResult.success ? "text-green-400" : "text-red-400"}`}>
{testResult.success
? testResult.status_code
? `Connection successful! (HTTP ${testResult.status_code})`
: "Connection successful!"
: `Connection failed: ${testResult.error || "Unknown error"}`}
</p>
)}
</div>

{error && <p className="text-red-400 text-sm">{error}</p>}
Expand Down
4 changes: 2 additions & 2 deletions soroscan-frontend/app/webhooks/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
setTimeoutError(null)
setSaveMessage(null)
}
}, [webhook?.id])

Check warning on line 65 in soroscan-frontend/app/webhooks/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

React Hook React.useEffect has a missing dependency: 'webhook'. Either include it or remove the dependency array

const parseTimeout = (value: string) => {
const parsed = Number(value)
Expand All @@ -87,13 +87,13 @@

const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTimeoutInput(e.target.value)
setTimeoutError(null)
setTimeoutError(validateTimeout(parseTimeout(e.target.value)))
setSaveMessage(null)
}

const handleTimeoutSuggestion = (value: number) => {
setTimeoutInput(String(value))
setTimeoutError(null)
setTimeoutError(validateTimeout(value))
setSaveMessage(null)
}

Expand Down
56 changes: 52 additions & 4 deletions soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface CreateWebhookModalProps {
function isValidUrl(str: string): boolean {
try {
const u = new URL(str)
return u.protocol === "http:" || u.protocol === "https:"
return u.protocol === "https:"
} catch {
return false
}
Expand All @@ -41,12 +41,40 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM
const [timeoutInput, setTimeoutInput] = React.useState("30")
const [timeoutTouched, setTimeoutTouched] = React.useState(false)
const [submitting, setSubmitting] = React.useState(false)
const [testing, setTesting] = React.useState(false)
const [testResult, setTestResult] = React.useState<{ success: boolean; status_code?: number; error?: string } | null>(null)

const urlValid = isValidUrl(url)
const timeoutValue = Number(timeoutInput)
const timeoutValid = Number.isInteger(timeoutValue) && timeoutValue >= 5 && timeoutValue <= 60
const urlError = urlTouched && !urlValid ? "Must be a valid https:// URL" : null
const timeoutError = timeoutTouched && !timeoutValid ? "Timeout must be a whole number between 5 and 60 seconds." : null
const urlError = (urlTouched || url.length > 0) && !urlValid ? (url ? "Must be a valid HTTPS URL" : "URL is required") : null
const timeoutError = (timeoutTouched || timeoutInput.length > 0) && !timeoutValid ? "Timeout must be a whole number between 5 and 60 seconds." : null

const handleTestConnection = async () => {
if (!urlValid) return
setTesting(true)
setTestResult(null)
try {
const response = await fetch("/api/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `
mutation TestWebhookUrl($url: String!, $timeoutSeconds: Int!) {
testWebhookUrl(url: $url, timeoutSeconds: $timeoutSeconds)
}
`,
variables: { url, timeoutSeconds: timeoutValue },
}),
})
const result = await response.json()
setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" })
} catch {
setTestResult({ success: false, error: "Failed to test connection" })
} finally {
setTesting(false)
}
}

const toggleType = (t: EventType) => {
if (t === "ALL") {
Expand Down Expand Up @@ -95,7 +123,7 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM
<Modal isOpen={isOpen} onClose={handleClose} title="NEW_WEBHOOK_SUBSCRIPTION">
<form onSubmit={handleSubmit} className="space-y-5 text-sm">
{/* URL */}
<div>
<div className="space-y-2">
<Input
id="webhook-url-input"
label="ENDPOINT_URL *"
Expand All @@ -106,6 +134,26 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM
onBlur={() => setUrlTouched(true)}
aria-invalid={!!urlError}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
disabled={testing || !urlValid}
onClick={handleTestConnection}
className="text-xs px-3 py-1"
>
{testing ? "Testing..." : "Test Connection"}
</Button>
{testResult && (
<span className={`text-xs ${testResult.success ? "text-green-400" : "text-red-400"}`}>
{testResult.success
? testResult.status_code
? `Success! (HTTP ${testResult.status_code})`
: "Success!"
: `Failed: ${testResult.error || "Unknown error"}`}
</span>
)}
</div>
{urlError && (
<p className="text-terminal-danger text-[10px] mt-1 ml-1">{urlError}</p>
)}
Expand Down
Loading