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
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ DASHBOARD_IMAGE := claworc/claworc
TAG := latest
PLATFORMS := linux/amd64,linux/arm64
NATIVE_ARCH := $(shell uname -m | sed 's/x86_64/amd64/')
# Go-installed dev tools (goreman, air) land here; not always on the user's PATH.
GOBIN_DIR := $(shell go env GOPATH)/bin

CACHE_ARGS ?=

Expand Down Expand Up @@ -177,7 +179,7 @@ dev:
@echo "Control plane: http://localhost:8000"
@echo "Frontend: http://localhost:5173"
@echo ""
CLAWORC_AUTH_DISABLED=true CLAWORC_LLM_RESPONSE_LOG=$(CURDIR)/llm-responses.log CLAWORC_ALLOWED_HOST_MOUNTS=/tmp,~/ goreman -set-ports=false start
PATH="$(GOBIN_DIR):$$PATH" CLAWORC_AUTH_DISABLED=true CLAWORC_LLM_RESPONSE_LOG=$(CURDIR)/llm-responses.log CLAWORC_ALLOWED_HOST_MOUNTS=/tmp,~/ goreman -set-ports=false start

ssh-integration-test:
docker build -f agent/instance/Dockerfile -t claworc-agent:local agent/instance/
Expand Down
4 changes: 4 additions & 0 deletions control-plane/frontend/src/app/pages/AgentDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import AffinityEditor from "@common/components/AffinityEditor";
import PortsEditor from "@common/components/PortsEditor";
import { useHealth } from "@common/hooks/useHealth";
import WebhookSection from "@common/components/WebhookSection";
import ChannelHealthPanel from "@common/components/ChannelHealthPanel";
import LegacyBrowserBanner from "@common/components/LegacyBrowserBanner";
import AppToast from "@common/components/AppToast";
import { infoToast } from "@common/utils/toast";
Expand Down Expand Up @@ -1153,6 +1154,9 @@ export default function AgentDetailPage() {
{/* Webhook (per-instance) — admins and team managers */}
<WebhookSection instanceId={instanceId} />

{/* Chat channel health */}
<ChannelHealthPanel instanceId={instanceId} />

{/* SSH Connection Status */}
<SSHStatus
status={sshStatus.data}
Expand Down
173 changes: 173 additions & 0 deletions control-plane/frontend/src/app/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSettings, useUpdateSettings } from "@common/hooks/useSettings";
import { useProviders, useCatalogIconMap } from "@common/hooks/useProviders";
import { fetchSSHFingerprint, rotateSSHKey } from "@common/api/ssh";
import { testChannelAlertWebhook } from "@common/api/settings";
import { syncAllProviders } from "@common/api/llm";
import { successToast, errorToast } from "@common/utils/toast";
import { validateResourceQuantities } from "@common/utils/resourceValidation";
Expand Down Expand Up @@ -800,6 +801,8 @@ function MiscTab({
)}
</div>

<ChannelAlertsCard settings={settings} />

<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-sm font-medium text-gray-900 mb-1">Anonymous Analytics</h3>
<p className="text-xs text-gray-500 mb-4">
Expand Down Expand Up @@ -835,3 +838,173 @@ function MiscTab({
</div>
);
}

function ChannelAlertsCard({ settings }: { settings: Settings }) {
const updateMutation = useUpdateSettings();

const [url, setUrl] = useState(settings.channel_alert_webhook_url || "");
const [alertsEnabled, setAlertsEnabled] = useState(
settings.channel_alerts_enabled !== "false",
);
const [autoRestart, setAutoRestart] = useState(
settings.channel_auto_restart_enabled === "true",
);
const [editingToken, setEditingToken] = useState(false);
const [showToken, setShowToken] = useState(false);
const [tokenValue, setTokenValue] = useState("");
const [pendingToken, setPendingToken] = useState<string | null>(null);

const dirty =
url !== (settings.channel_alert_webhook_url || "") ||
alertsEnabled !== (settings.channel_alerts_enabled !== "false") ||
autoRestart !== (settings.channel_auto_restart_enabled === "true") ||
pendingToken !== null;

const save = () => {
const payload: SettingsUpdatePayload = {
channel_alert_webhook_url: url.trim(),
channel_alerts_enabled: alertsEnabled ? "true" : "false",
channel_auto_restart_enabled: autoRestart ? "true" : "false",
};
if (pendingToken !== null) payload.channel_alert_webhook_token = pendingToken;
updateMutation.mutate(payload, {
onSuccess: () => {
setEditingToken(false);
setTokenValue("");
setPendingToken(null);
},
});
};

const testMutation = useMutation({
mutationFn: testChannelAlertWebhook,
onSuccess: (res) => {
if (res.status === "sent") {
successToast("Test alert delivered", `Webhook responded with HTTP ${res.http_status}`);
} else {
errorToast("Test alert failed", res.error || `HTTP ${res.http_status}`);
}
},
onError: (err) => errorToast("Test alert failed", err),
});

return (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-sm font-medium text-gray-900 mb-1">Channel Health Alerts</h3>
<p className="text-xs text-gray-500 mb-4">
Get notified when an Agent's chat channels (Slack, Telegram, ...) stop responding.
Alerts are POSTed as JSON to the webhook URL below (Slack incoming webhooks work as-is).
</p>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Webhook URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hooks.slack.com/services/..."
className="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Bearer Token (optional)</label>
{editingToken ? (
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showToken ? "text" : "password"}
value={tokenValue}
onChange={(e) => {
setTokenValue(e.target.value);
setPendingToken(e.target.value);
}}
className="w-full px-3 py-1.5 pr-10 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Sent as Authorization: Bearer <token>"
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<button
type="button"
onClick={() => {
setEditingToken(false);
setTokenValue("");
setPendingToken(null);
}}
className="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 font-mono">
{pendingToken !== null
? pendingToken
? "****" + pendingToken.slice(-4)
: "(not set)"
: settings.channel_alert_webhook_token || "(not set)"}
</span>
<button
type="button"
onClick={() => setEditingToken(true)}
className="text-xs text-blue-600 hover:text-blue-800"
>
Change
</button>
</div>
)}
</div>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={alertsEnabled}
onChange={(e) => setAlertsEnabled(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Send alert notifications</span>
</label>
<div>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={autoRestart}
onChange={(e) => setAutoRestart(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Auto-restart Agents with unhealthy channels</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6 flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-500" />
Restarts the Agent's container after several consecutive failed checks
(max 3 restarts per hour). Thresholds are set via CLAWORC_CHANNEL_HEALTH_* env vars.
</p>
</div>
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={save}
disabled={!dirty || updateMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateMutation.isPending ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending || !settings.channel_alert_webhook_url}
title={!settings.channel_alert_webhook_url ? "Save a webhook URL first" : undefined}
className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testMutation.isPending ? "Sending..." : "Send Test"}
</button>
</div>
</div>
</div>
);
}
7 changes: 7 additions & 0 deletions control-plane/frontend/src/common/api/channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import client from "./client";
import type { ChannelHealth } from "@common/types/channel";

export async function getChannelHealth(instanceId: number): Promise<ChannelHealth> {
const { data } = await client.get<ChannelHealth>(`/instances/${instanceId}/channels/health`);
return data;
}
13 changes: 13 additions & 0 deletions control-plane/frontend/src/common/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,16 @@ export async function updateSettings(
const { data } = await client.put<Settings>("/settings", payload);
return data;
}

export interface ChannelAlertTestResult {
status: "sent" | "failed";
http_status?: number;
error?: string;
}

export async function testChannelAlertWebhook(): Promise<ChannelAlertTestResult> {
const { data } = await client.post<ChannelAlertTestResult>(
"/settings/channel-alerts/test",
);
return data;
}
6 changes: 5 additions & 1 deletion control-plane/frontend/src/common/components/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { formatDistanceToNow } from "date-fns";
import { GripVertical } from "lucide-react";
import StatusBadge from "./StatusBadge";
import ActionButtons from "./ActionButtons";
import { ChannelHealthIndicator } from "./ChannelHealthPanel";
import { useSSHStatus } from "@common/hooks/useSSHStatus";
import { buildSSHTooltip } from "@common/utils/sshTooltip";
import type { Instance } from "@common/types/instance";
Expand Down Expand Up @@ -61,7 +62,10 @@ export default function AgentCard({
{instance.display_name}
</Link>
</div>
<StatusBadge status={instance.status} tooltip={tooltip} />
<div className="flex items-center gap-1.5 shrink-0">
<StatusBadge status={instance.status} tooltip={tooltip} />
<ChannelHealthIndicator instance={instance} />
</div>
</div>
<div className="text-xs text-gray-500">{createdAt}</div>
<div className="flex justify-end">
Expand Down
20 changes: 12 additions & 8 deletions control-plane/frontend/src/common/components/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { formatDistanceToNow } from "date-fns";
import { GripVertical } from "lucide-react";
import StatusBadge from "./StatusBadge";
import ActionButtons from "./ActionButtons";
import { ChannelHealthIndicator } from "./ChannelHealthPanel";
import { useSSHStatus } from "@common/hooks/useSSHStatus";
import { buildSSHTooltip } from "@common/utils/sshTooltip";
import type { Instance } from "@common/types/instance";
Expand Down Expand Up @@ -58,14 +59,17 @@ export default function AgentRow({
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge
status={instance.status}
tooltip={
instance.status === "creating" && instance.status_message
? instance.status_message
: buildSSHTooltip(sshStatus.data)
}
/>
<div className="flex items-center gap-1.5">
<StatusBadge
status={instance.status}
tooltip={
instance.status === "creating" && instance.status_message
? instance.status_message
: buildSSHTooltip(sshStatus.data)
}
/>
<ChannelHealthIndicator instance={instance} />
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{createdAt}</td>
<td className="px-4 py-3">
Expand Down
Loading
Loading