Skip to content

Commit ce42395

Browse files
authored
fix: indicates squadhub restart status (#56)
1 parent 50b4e07 commit ce42395

File tree

19 files changed

+233
-1810
lines changed

19 files changed

+233
-1810
lines changed

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"react-markdown": "^10.1.0",
5353
"rehype-raw": "^7.0.0",
5454
"remark-gfm": "^4.0.1",
55+
"sonner": "^2.0.7",
5556
"tippy.js": "^6.3.7",
5657
"ws": "^8.19.0",
5758
"zod": "^4.3.6"

apps/web/src/app/(dashboard)/settings/api-keys/_components/api-keys-settings.tsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Label } from "@clawe/ui/components/label";
1010
import { Spinner } from "@clawe/ui/components/spinner";
1111
import { Skeleton } from "@clawe/ui/components/skeleton";
1212
import { CheckCircle2, Eye, EyeOff, Pencil } from "lucide-react";
13+
import { toast } from "sonner";
1314
import { loadPlugins, hasPlugin } from "@clawe/plugins";
1415
import { patchApiKeys } from "@/lib/squadhub/actions";
1516
import { useApiClient } from "@/hooks/use-api-client";
@@ -31,9 +32,6 @@ interface KeyRowProps {
3132
onValidate: () => void;
3233
onSave: () => void;
3334
isSaving: boolean;
34-
saveSuccess: boolean;
35-
saveError?: string;
36-
isCloud: boolean;
3735
}
3836

3937
const KeyRow = ({
@@ -52,9 +50,6 @@ const KeyRow = ({
5250
onValidate,
5351
onSave,
5452
isSaving,
55-
saveSuccess,
56-
saveError,
57-
isCloud,
5853
}: KeyRowProps) => {
5954
const [showKey, setShowKey] = useState(false);
6055

@@ -171,12 +166,6 @@ const KeyRow = ({
171166
>
172167
Cancel
173168
</Button>
174-
{saveSuccess && (
175-
<p className="text-sm text-green-600 dark:text-green-400">
176-
{isCloud ? "Saved and applied" : "Saved and applied"}
177-
</p>
178-
)}
179-
{saveError && <p className="text-destructive text-sm">{saveError}</p>}
180169
</div>
181170
</div>
182171
);
@@ -240,6 +229,10 @@ export const ApiKeysSettings = () => {
240229
setAnthropicKey("");
241230
setAnthropicValid(null);
242231
anthropicValidation.reset();
232+
toast.success("Anthropic API key saved and applied");
233+
},
234+
onError: (error) => {
235+
toast.error(error.message || "Failed to save Anthropic API key");
243236
},
244237
});
245238

@@ -256,6 +249,10 @@ export const ApiKeysSettings = () => {
256249
setOpenaiKey("");
257250
setOpenaiValid(null);
258251
openaiValidation.reset();
252+
toast.success("OpenAI API key saved and applied");
253+
},
254+
onError: (error) => {
255+
toast.error(error.message || "Failed to save OpenAI API key");
259256
},
260257
});
261258

@@ -302,11 +299,6 @@ export const ApiKeysSettings = () => {
302299
onValidate={() => anthropicValidation.mutate(anthropicKey)}
303300
onSave={() => anthropicSave.mutate(anthropicKey)}
304301
isSaving={anthropicSave.isPending}
305-
saveSuccess={anthropicSave.isSuccess}
306-
saveError={
307-
anthropicSave.isError ? anthropicSave.error.message : undefined
308-
}
309-
isCloud={isCloud}
310302
/>
311303

312304
<KeyRow
@@ -337,9 +329,6 @@ export const ApiKeysSettings = () => {
337329
onValidate={() => openaiValidation.mutate(openaiKey)}
338330
onSave={() => openaiSave.mutate(openaiKey)}
339331
isSaving={openaiSave.isPending}
340-
saveSuccess={openaiSave.isSuccess}
341-
saveError={openaiSave.isError ? openaiSave.error.message : undefined}
342-
isCloud={isCloud}
343332
/>
344333
</div>
345334
</div>

apps/web/src/app/(dashboard)/settings/business/_components/business-settings-form.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Input } from "@clawe/ui/components/input";
88
import { Label } from "@clawe/ui/components/label";
99
import { Textarea } from "@clawe/ui/components/textarea";
1010
import { Spinner } from "@clawe/ui/components/spinner";
11+
import { toast } from "sonner";
1112
import { Globe, Building2, Users, Palette } from "lucide-react";
1213

1314
export const BusinessSettingsForm = () => {
@@ -62,6 +63,9 @@ export const BusinessSettingsForm = () => {
6263
},
6364
});
6465
setIsDirty(false);
66+
toast.success("Business settings saved");
67+
} catch {
68+
toast.error("Failed to save business settings");
6569
} finally {
6670
setIsSaving(false);
6771
}

apps/web/src/app/(dashboard)/settings/general/_components/general-settings-form.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
"use client";
22

33
import { useState, useEffect } from "react";
4+
import { useMutation } from "convex/react";
5+
import { api } from "@clawe/backend";
46
import { Button } from "@clawe/ui/components/button";
57
import { Input } from "@clawe/ui/components/input";
68
import { Label } from "@clawe/ui/components/label";
9+
import { Spinner } from "@clawe/ui/components/spinner";
10+
import { toast } from "sonner";
711
import { useSquad } from "@/providers/squad-provider";
812

913
export const GeneralSettingsForm = () => {
1014
const { selectedSquad } = useSquad();
15+
const updateGeneral = useMutation(api.tenants.updateGeneral);
1116

1217
const [name, setName] = useState("");
1318
const [description, setDescription] = useState("");
1419
const [isDirty, setIsDirty] = useState(false);
20+
const [isSaving, setIsSaving] = useState(false);
1521

1622
useEffect(() => {
1723
if (selectedSquad) {
@@ -31,11 +37,23 @@ export const GeneralSettingsForm = () => {
3137
setIsDirty(true);
3238
};
3339

34-
const handleSubmit = (e: React.FormEvent) => {
40+
const handleSubmit = async (e: React.FormEvent) => {
3541
e.preventDefault();
36-
if (!selectedSquad || !isDirty) return;
37-
// TODO: Implement squad update
38-
setIsDirty(false);
42+
if (!selectedSquad || !isDirty || !name.trim()) return;
43+
44+
setIsSaving(true);
45+
try {
46+
await updateGeneral({
47+
name: name.trim(),
48+
description: description.trim() || undefined,
49+
});
50+
setIsDirty(false);
51+
toast.success("Settings saved");
52+
} catch {
53+
toast.error("Failed to save settings");
54+
} finally {
55+
setIsSaving(false);
56+
}
3957
};
4058

4159
if (!selectedSquad) {
@@ -70,8 +88,19 @@ export const GeneralSettingsForm = () => {
7088
</p>
7189
</div>
7290

73-
<Button type="submit" variant="brand" disabled={!isDirty}>
74-
Save changes
91+
<Button
92+
type="submit"
93+
variant="brand"
94+
disabled={!isDirty || !name.trim() || isSaving}
95+
>
96+
{isSaving ? (
97+
<>
98+
<Spinner />
99+
Saving...
100+
</>
101+
) : (
102+
"Save changes"
103+
)}
75104
</Button>
76105
</form>
77106
);

apps/web/src/app/(dashboard)/settings/general/_components/timezone-settings.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ComboboxEmpty,
1717
} from "@clawe/ui/components/combobox";
1818
import { Skeleton } from "@clawe/ui/components/skeleton";
19+
import { toast } from "sonner";
1920

2021
export const TimezoneSettings = () => {
2122
const timezone = useQuery(api.tenants.getTimezone, {});
@@ -60,10 +61,15 @@ export const TimezoneSettings = () => {
6061
return <TimezoneSettingsSkeleton />;
6162
}
6263

63-
const handleTimezoneChange = (value: string | null) => {
64+
const handleTimezoneChange = async (value: string | null) => {
6465
if (value) {
65-
setTimezone({ timezone: value });
66-
setSearch("");
66+
try {
67+
await setTimezone({ timezone: value });
68+
setSearch("");
69+
toast.success("Timezone updated");
70+
} catch {
71+
toast.error("Failed to update timezone");
72+
}
6773
}
6874
};
6975

apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-disconnect-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from "react";
44
import { useMutation as useConvexMutation } from "convex/react";
5-
import { toast } from "@clawe/ui/components/sonner";
5+
import { toast } from "sonner";
66
import { api } from "@clawe/backend";
77
import { Button } from "@clawe/ui/components/button";
88
import { Spinner } from "@clawe/ui/components/spinner";

apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-integration-card.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export const TelegramIntegrationCard = () => {
2323

2424
const isLoading = channel === undefined;
2525
const isConnected = channel?.status === "connected";
26-
const isOffline = !isSquadhubLoading && squadhubStatus === "down";
26+
const isOffline =
27+
!isSquadhubLoading &&
28+
(squadhubStatus === "down" || squadhubStatus === "restarting");
2729

2830
if (isLoading) {
2931
return <TelegramCardSkeleton />;

apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-remove-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from "react";
44
import { useMutation as useConvexMutation } from "convex/react";
5-
import { toast } from "@clawe/ui/components/sonner";
5+
import { toast } from "sonner";
66
import { api } from "@clawe/backend";
77
import { Button } from "@clawe/ui/components/button";
88
import { Spinner } from "@clawe/ui/components/spinner";

apps/web/src/app/(dashboard)/settings/integrations/_components/telegram-setup-dialog.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useState } from "react";
44
import Image from "next/image";
55
import { useMutation } from "@tanstack/react-query";
66
import { useMutation as useConvexMutation } from "convex/react";
7-
import { toast } from "@clawe/ui/components/sonner";
7+
import { toast } from "sonner";
88
import { Copy, Check, AlertTriangle } from "lucide-react";
99
import { api } from "@clawe/backend";
1010
import { Button } from "@clawe/ui/components/button";
@@ -38,7 +38,8 @@ export const TelegramSetupDialog = ({
3838
onOpenChange,
3939
}: TelegramSetupDialogProps) => {
4040
const { status, isLoading: isSquadhubLoading } = useSquadhubStatus();
41-
const isOffline = !isSquadhubLoading && status === "down";
41+
const isOffline =
42+
!isSquadhubLoading && (status === "down" || status === "restarting");
4243

4344
const [step, setStep] = useState<Step>("token");
4445
const [botToken, setBotToken] = useState("");
Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,62 @@
11
import { NextResponse } from "next/server";
22
import type { NextRequest } from "next/server";
33
import { checkHealth } from "@clawe/shared/squadhub";
4+
import { loadPlugins, getPlugin } from "@clawe/plugins";
45
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
56
import { getConnection } from "@/lib/squadhub/connection";
7+
import { config } from "@/lib/config";
8+
9+
// Track when each tenant's gateway was last known healthy.
10+
// During a restart the process is briefly down (ECONNREFUSED), making
11+
// an HTTP probe indistinguishable from a real outage. This lets us
12+
// infer "restarting" when the gateway was healthy moments ago.
13+
const lastHealthyAt = new Map<string, number>();
14+
const RESTART_GRACE_MS = 30_000;
15+
16+
async function isInfraRunning(
17+
tenantId: string,
18+
squadhubUrl: string,
19+
): Promise<boolean> {
20+
try {
21+
if (config.isCloud) {
22+
await loadPlugins();
23+
const lifecycle = getPlugin("squadhub-lifecycle");
24+
const status = await lifecycle.getStatus(tenantId);
25+
return status.running;
26+
}
27+
28+
// Dev/OSS: HTTP probe — any response means the process is alive
29+
await fetch(squadhubUrl, {
30+
method: "HEAD",
31+
signal: AbortSignal.timeout(3000),
32+
});
33+
return true;
34+
} catch {
35+
// Probe failed (ECONNREFUSED). Check if gateway was recently healthy —
36+
// during a restart the process is briefly down but should come back.
37+
const lastHealthy = lastHealthyAt.get(tenantId);
38+
if (lastHealthy && Date.now() - lastHealthy < RESTART_GRACE_MS) {
39+
return true;
40+
}
41+
return false;
42+
}
43+
}
644

745
export async function POST(request: NextRequest) {
846
const auth = await getAuthenticatedTenant(request);
947
if (auth.error) return auth.error;
1048

11-
const result = await checkHealth(getConnection(auth.tenant));
12-
return NextResponse.json(result);
49+
const connection = getConnection(auth.tenant);
50+
const result = await checkHealth(connection);
51+
52+
if (result.ok) {
53+
lastHealthyAt.set(auth.tenant._id, Date.now());
54+
return NextResponse.json(result);
55+
}
56+
57+
const restarting = await isInfraRunning(
58+
auth.tenant._id,
59+
connection.squadhubUrl,
60+
);
61+
return NextResponse.json({ ...result, restarting });
1362
}

0 commit comments

Comments
 (0)