Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/app/(app)/claw/components/ClawDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export function ClawDashboard({
const onSecretsChanged = useCallback((entryId: string) => {
setDirtySecrets(prev => new Set([...prev, entryId]));
}, []);
const [upgradeRequested, setUpgradeRequested] = useState(false);
const onRequestUpgrade = useCallback(() => setUpgradeRequested(true), []);
const onUpgradeHandled = useCallback(() => setUpgradeRequested(false), []);

const onRedeploySuccess = useCallback(() => {
setDirtySecrets(new Set());
}, []);
Expand Down Expand Up @@ -236,6 +240,8 @@ export function ClawDashboard({
status={instanceStatus}
mutations={mutations}
onRedeploySuccess={onRedeploySuccess}
upgradeRequested={upgradeRequested}
onUpgradeHandled={onUpgradeHandled}
/>
</CardContent>
<Tabs defaultValue="instance">
Expand Down Expand Up @@ -276,6 +282,7 @@ export function ClawDashboard({
mutations={mutations}
onSecretsChanged={onSecretsChanged}
dirtySecrets={dirtySecrets}
onRequestUpgrade={onRequestUpgrade}
/>
</TabsContent>
<TabsContent value="changelog" className="mt-0">
Expand Down
17 changes: 16 additions & 1 deletion src/app/(app)/claw/components/InstanceControls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Cpu, HardDrive, Play, RefreshCw, RotateCw, Stethoscope } from 'lucide-react';
import { usePostHog } from 'posthog-js/react';
import { toast } from 'sonner';
Expand Down Expand Up @@ -37,10 +37,14 @@ export function InstanceControls({
status,
mutations,
onRedeploySuccess,
upgradeRequested,
onUpgradeHandled,
}: {
status: KiloClawDashboardStatus;
mutations: ClawMutations;
onRedeploySuccess?: () => void;
upgradeRequested?: boolean;
onUpgradeHandled?: () => void;
}) {
const posthog = usePostHog();
const isRunning = status.status === 'running';
Expand All @@ -57,6 +61,17 @@ export function InstanceControls({
const [confirmRedeploy, setConfirmRedeploy] = useState(false);
const [redeployMode, setRedeployMode] = useState<'redeploy' | 'upgrade'>('redeploy');

// Toggle-flag pattern: parent sets upgradeRequested=true, we open the dialog
// with "upgrade" preselected, then immediately reset via onUpgradeHandled.
// Safe for single-click flows; won't re-fire if already true (no state change).
useEffect(() => {
if (upgradeRequested) {
setRedeployMode('upgrade');
setConfirmRedeploy(true);
onUpgradeHandled?.();
}
}, [upgradeRequested, onUpgradeHandled]);

return (
<div>
<div className="mb-4 flex items-start justify-between gap-4">
Expand Down
51 changes: 36 additions & 15 deletions src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,13 @@ export function SettingsTab({
mutations,
onSecretsChanged,
dirtySecrets,
onRequestUpgrade,
}: {
status: KiloClawDashboardStatus;
mutations: ClawMutations;
onSecretsChanged?: (entryId: string) => void;
dirtySecrets: Set<string>;
onRequestUpgrade?: () => void;
}) {
const posthog = usePostHog();
const { data: config } = useKiloClawConfig();
Expand Down Expand Up @@ -459,14 +461,27 @@ export function SettingsTab({
!!latestAvailableVersion &&
latestAvailableVersion !== trackedVersion &&
calverAtLeast(latestAvailableVersion, trackedVersion);
const updateAvailable =
catalogNewerThanImage &&
(!isModified ||
(!!runningVersion &&
calverAtLeast(latestAvailableVersion, runningVersion) &&
latestAvailableVersion !== runningVersion));
const isPinned = !!myPin;
const hasVersionInfo = isRunning && trackedVersion && trackedVersion !== ':latest';
// Only compare image tags when variants match — latestVersion is always
// for the "default" variant, so skip for non-default instances to avoid
// false "Update available" badges that would switch their variant.
const variantsMatch =
!status.imageVariant ||
status.imageVariant === 'default' ||
status.imageVariant === latestVersion?.variant;
const imageTagDiffers =
hasVersionInfo &&
variantsMatch &&
!!status.trackedImageTag &&
!!latestVersion?.imageTag &&
status.trackedImageTag !== latestVersion.imageTag;
const updateAvailable = catalogNewerThanImage
? !isModified ||
(!!runningVersion &&
calverAtLeast(latestAvailableVersion, runningVersion) &&
latestAvailableVersion !== runningVersion)
: imageTagDiffers;

return (
<div className="flex flex-col gap-6">
Expand Down Expand Up @@ -519,17 +534,20 @@ export function SettingsTab({
{updateAvailable && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="border-orange-500/30 bg-orange-500/15 text-orange-400"
>
Update available
</Badge>
<button type="button" onClick={onRequestUpgrade} className="cursor-pointer">
<Badge
variant="outline"
className="border-orange-500/30 bg-orange-500/15 text-orange-400 hover:bg-orange-500/25"
>
Update available
</Badge>
</button>
</TooltipTrigger>
<TooltipContent>
<p>
A newer OpenClaw version ({latestAvailableVersion}) is available —
redeploy to upgrade
{catalogNewerThanImage
? `A newer OpenClaw version (${latestAvailableVersion}) is available — click to upgrade`
: `A newer image (${latestVersion?.imageTag ?? 'unknown'}) is available — click to upgrade`}
</p>
</TooltipContent>
</Tooltip>
Expand Down Expand Up @@ -564,7 +582,10 @@ export function SettingsTab({
{/* Expandable version pinning */}
{manageVersionOpen && (
<div className="mt-4 border-t pt-4">
<VersionPinCard />
<VersionPinCard
trackedImageTag={status.trackedImageTag}
latestImageTag={variantsMatch ? (latestVersion?.imageTag ?? null) : null}
/>
</div>
)}
</div>
Expand Down
191 changes: 117 additions & 74 deletions src/app/(app)/claw/components/VersionPinCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import {
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';

export function VersionPinCard() {
export function VersionPinCard({
trackedImageTag,
latestImageTag,
}: {
trackedImageTag: string | null;
latestImageTag: string | null;
}) {
const { data: myPin, isLoading: pinLoading } = useKiloClawMyPin();
const { data: versions, isLoading: versionsLoading } = useKiloClawAvailableVersions(0, 50);
const mutations = useKiloClawMutations();
Expand Down Expand Up @@ -94,20 +100,76 @@ export function VersionPinCard() {
<Pin className="size-4" />
Version Pinning
</h3>
{/* Description + Current Status */}
<div className="mb-6 grid grid-cols-2 items-start gap-6">
{/* Left: Description + Info */}
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
Pin your instance to a specific OpenClaw version or follow the latest
</p>
<div className="text-muted-foreground flex items-start gap-1 text-xs">
<Info className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Pinning locks your instance to a specific version. You won&apos;t receive automatic
updates until you unpin.
</span>
<div className="grid grid-cols-2 items-start gap-6">
{/* Left: Description + Pinning Controls */}
<div className="space-y-3">
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
Pin your instance to a specific OpenClaw version or follow the latest
</p>
<div className="text-muted-foreground flex items-start gap-1 text-xs">
<Info className="mt-0.5 h-3 w-3 shrink-0" />
<span>
Pinning locks your instance to a specific version. You won&apos;t receive automatic
updates until you unpin.
</span>
</div>
</div>

{!isPinned ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="version-select" className="text-sm">
Select Version
</Label>
<div className="flex items-center gap-2">
<Select value={selectedImageTag} onValueChange={setSelectedImageTag}>
<SelectTrigger id="version-select">
<SelectValue placeholder="Choose a version to pin..." />
</SelectTrigger>
<SelectContent>
{versions?.items.map(version => (
<SelectItem key={version.image_tag} value={version.image_tag}>
<div className="flex flex-col">
<span className="font-medium">
{version.openclaw_version} / {version.variant}
</span>
<span
className="text-muted-foreground text-xs"
title={version.image_tag}
>
{truncateTag(version.image_tag)}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handlePin}
disabled={!selectedImageTag || isPinning}
size="sm"
className="shrink-0"
>
{isPinning ? 'Pinning...' : 'Pin to this version'}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="pin-reason" className="text-sm">
Reason (optional)
</Label>
<Textarea
id="pin-reason"
placeholder="Why are you pinning to this version?"
value={reason}
onChange={e => setReason(e.target.value)}
rows={3}
maxLength={500}
/>
</div>
</div>
) : null}
</div>

{/* Right: Current Status */}
Expand Down Expand Up @@ -168,70 +230,51 @@ export function VersionPinCard() {
</div>
</div>
) : (
<div className="flex items-center gap-2">
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-100">
Following latest
</span>
<span className="text-muted-foreground text-xs">
Automatically uses newest version
</span>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-100">
Following latest
</span>
<span className="text-muted-foreground text-xs">
Automatically uses newest version
</span>
</div>
{(trackedImageTag || latestImageTag) && (
<table className="text-sm">
<tbody>
{trackedImageTag && (
<tr>
<td className="text-muted-foreground pr-3 align-top">Current image</td>
<td>
<code
className="bg-muted rounded px-1.5 py-0.5 text-xs"
title={trackedImageTag}
>
{truncateTag(trackedImageTag)}
</code>
</td>
</tr>
)}
{latestImageTag && (
<tr>
<td className="text-muted-foreground pr-3 pt-1 align-top">Latest image</td>
<td className="pt-1">
<code
className="bg-muted rounded px-1.5 py-0.5 text-xs"
title={latestImageTag}
>
{truncateTag(latestImageTag)}
</code>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
)}
</div>
</div>

{/* Row 3: Pin/Unpin Controls */}
{!isPinned ? (
<div className="grid grid-cols-2 items-start gap-6">
{/* Left Column: Version Selector + Reason */}
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="version-select" className="text-sm">
Select Version
</Label>
<Select value={selectedImageTag} onValueChange={setSelectedImageTag}>
<SelectTrigger id="version-select">
<SelectValue placeholder="Choose a version to pin..." />
</SelectTrigger>
<SelectContent>
{versions?.items.map(version => (
<SelectItem key={version.image_tag} value={version.image_tag}>
<div className="flex flex-col">
<span className="font-medium">
{version.openclaw_version} / {version.variant}
</span>
<span className="text-muted-foreground text-xs" title={version.image_tag}>
{truncateTag(version.image_tag)}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="pin-reason" className="text-sm">
Reason (optional)
</Label>
<Textarea
id="pin-reason"
placeholder="Why are you pinning to this version?"
value={reason}
onChange={e => setReason(e.target.value)}
rows={3}
maxLength={500}
/>
</div>
</div>

{/* Right Column: Pin Button */}
<div>
<Button onClick={handlePin} disabled={!selectedImageTag || isPinning} size="sm">
{isPinning ? 'Pinning...' : 'Pin to this version'}
</Button>
</div>
</div>
) : null}
</div>
);
}
Loading