mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat(ui): add typed update recovery actions (#125098)
* feat(ui): add typed update recovery actions * fix(ui): surface update status refresh failures * fix(ui): keep update failure type internal * fix(ui): publish update failure facts atomically * fix(ui): validate update recovery startup budget --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bed37ec377
commit
e349d520a2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"startupJsGzipBytes": 340994,
|
||||
"reason": "Ask OpenClaw companion: shell chrome toggle, palette action, scope-aware gating",
|
||||
"updatedAt": "2026-08-17"
|
||||
"startupJsGzipBytes": 343289,
|
||||
"reason": "Typed update recovery state, failure projection, and sidebar review routing",
|
||||
"updatedAt": "2026-08-18"
|
||||
}
|
||||
|
||||
@@ -1567,6 +1567,10 @@
|
||||
"source": "Troubleshooting",
|
||||
"target": "故障排查"
|
||||
},
|
||||
{
|
||||
"source": "Update troubleshooting",
|
||||
"target": "更新故障排查"
|
||||
},
|
||||
{
|
||||
"source": "Webhooks",
|
||||
"target": "Webhooks"
|
||||
|
||||
@@ -1088,6 +1088,7 @@
|
||||
"pages": [
|
||||
"install/backups",
|
||||
"install/updating",
|
||||
"install/update-troubleshooting",
|
||||
{
|
||||
"group": "Migrating",
|
||||
"pages": [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
summary: "Recover from failed OpenClaw updates in the Control UI or CLI"
|
||||
read_when:
|
||||
- An OpenClaw update failed
|
||||
- The Gateway did not report a final update result
|
||||
title: "Update troubleshooting"
|
||||
---
|
||||
|
||||
Start in **Control UI → Settings → Updates**. The page reads the latest recorded
|
||||
update attempt from the connected Gateway and shows its time, target, reason
|
||||
code, failing step, and bounded diagnostic detail.
|
||||
|
||||
Control UI remediation uses typed product actions only. It leads with an
|
||||
authenticated Gateway or native action when the connected UI has the required
|
||||
capability and scope, preserves confirmations for disruptive operations, and
|
||||
keeps terminal commands as secondary host-side fallbacks. It never parses
|
||||
localized guidance or executes an arbitrary command string.
|
||||
|
||||
## Recover in the Control UI
|
||||
|
||||
1. Select **Check status** when the Gateway restarted, disconnected, or did not
|
||||
report a final result. This reads `update.status`; it does not start another
|
||||
update. Recovery controls stay disabled while the check is pending, and a
|
||||
rejected request appears as an error on the page.
|
||||
2. Open **View details** and address the recorded failing step. Diagnostic text
|
||||
is bounded and redacted for display; use Gateway logs when more context is
|
||||
required.
|
||||
3. Select **Retry update** only after the cause is resolved. The Control UI uses
|
||||
the normal confirmed update flow and states that running sessions are
|
||||
interrupted while the Gateway restarts.
|
||||
|
||||
The controls require a connected Gateway, support for the corresponding typed
|
||||
Gateway method, and administrator scope. When those conditions are not met, use
|
||||
the CLI fallback on the Gateway host.
|
||||
|
||||
## Reason codes
|
||||
|
||||
- `dirty`, `no-upstream`: repair the source checkout before retrying.
|
||||
- `deps-install-failed`, `build-failed`, `ui-build-failed`: inspect the failing
|
||||
step, fix the dependency or build error, then retry.
|
||||
- `global-install-failed`: retry after checking package-manager ownership and
|
||||
permissions. Re-run the installer if the package install is incomplete.
|
||||
- `doctor-failed`: run Doctor on the Gateway host, resolve its findings, then
|
||||
retry.
|
||||
- `restart-disabled`, `restart-unavailable`: restore a supported supervisor or
|
||||
enable Gateway restarts before retrying.
|
||||
- `restart-unhealthy`, `restart-revision-mismatch`,
|
||||
`restart-revision-unavailable`: inspect Gateway service health and its install
|
||||
root before retrying.
|
||||
- `managed-service-handoff-*`: check status first. If the handoff stopped, use
|
||||
the CLI on the Gateway host to preserve the full diagnostic output.
|
||||
|
||||
Unknown reason codes remain visible. Check the Gateway logs before retrying.
|
||||
|
||||
## CLI fallback
|
||||
|
||||
Run these commands on the Gateway host, not on the computer that merely has the
|
||||
Control UI open:
|
||||
|
||||
```bash
|
||||
openclaw update status --json
|
||||
openclaw doctor --non-interactive
|
||||
openclaw update
|
||||
```
|
||||
|
||||
Use `openclaw update --dry-run` to preview a new attempt. If a package update
|
||||
failed after installation began, follow the installer recovery steps in
|
||||
[Updating](/install/updating#alternative-re-run-the-installer).
|
||||
|
||||
## Rollback boundary
|
||||
|
||||
Do not restore state as the first response to an update failure. First reinstall
|
||||
known-good code while preserving current state. Restore a verified pre-update
|
||||
state snapshot only when older code cannot read the current config or database.
|
||||
See [Rollback](/install/updating#rollback).
|
||||
|
||||
## Support diagnostics
|
||||
|
||||
Collect the following without posting credentials, raw config, or unredacted
|
||||
process output:
|
||||
|
||||
- OpenClaw version and install type;
|
||||
- update timestamp, target, phase, and reason code from Settings → Updates;
|
||||
- the bounded failure detail shown by **View details**;
|
||||
- `openclaw update status --json`;
|
||||
- `openclaw gateway status --deep --json`;
|
||||
- relevant redacted Gateway log lines.
|
||||
@@ -280,6 +280,10 @@ Off by default. Enable it in `~/.openclaw/openclaw.json`:
|
||||
|
||||
You can also choose the update channel and enable automatic updates from
|
||||
**Settings → Updates** (`/settings/updates`) in the Control UI.
|
||||
Recorded failures on that page include typed **Check status** and **Retry
|
||||
update** actions when the connected Gateway supports them. See [Update
|
||||
troubleshooting](/install/update-troubleshooting) for reason codes, guided
|
||||
recovery, CLI fallbacks, and diagnostics to collect.
|
||||
For a `dev` git install, opening this page refreshes the tracked upstream and
|
||||
shows whether the checkout is current, ahead, diverged, unavailable, or a
|
||||
specific number of commits behind. It also shows exact and relative build,
|
||||
|
||||
@@ -67,6 +67,7 @@ function createPairingShell(params: {
|
||||
updateAvailable: null,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
controlUiRefreshRequired: false,
|
||||
};
|
||||
const context = {
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("OpenClaw shell dock suppression", () => {
|
||||
updateAvailable: null,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
controlUiRefreshRequired: false,
|
||||
approvalQueue: [],
|
||||
approvalBusy: false,
|
||||
|
||||
@@ -398,6 +398,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
|
||||
onRefresh: () => host.refreshControlUi(),
|
||||
onHoldUpdate: () => context.overlays.holdUpdate(),
|
||||
onReviewUpdate: () => host.navigate("updates"),
|
||||
onOpenApprovals: () => host.openApprovals(),
|
||||
onRetryConnect: () => context.gateway.connect(),
|
||||
onOpenNewSession: openNewSession,
|
||||
@@ -434,6 +435,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
|
||||
onRefresh: () => host.refreshControlUi(),
|
||||
onHoldUpdate: () => context.overlays.holdUpdate(),
|
||||
onReviewUpdate: () => host.navigate("updates"),
|
||||
searchQuery: host.settingsSearchQuery,
|
||||
searchBlockMatches: settingsSearchBlocks,
|
||||
onExit: () => host.exitSettings(),
|
||||
@@ -621,6 +623,7 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
refreshRequired: overlaySnapshot.controlUiRefreshRequired,
|
||||
onRefresh: () => host.refreshControlUi(),
|
||||
onHoldUpdate: () => context.overlays.holdUpdate(),
|
||||
onReviewUpdate: () => host.navigate("updates"),
|
||||
})}
|
||||
${pageActionsBlocked && gatewaySnapshot.phase !== "reload-required"
|
||||
? html`<div class="connection-action-block" role="status" aria-live="polite">
|
||||
|
||||
@@ -28,6 +28,7 @@ export function renderFloatingUpdateCard(params: {
|
||||
refreshRequired: boolean;
|
||||
onRefresh: () => void;
|
||||
onHoldUpdate?: () => Promise<boolean>;
|
||||
onReviewUpdate?: () => void;
|
||||
}) {
|
||||
// A stale client must always have a visible refresh action, including during
|
||||
// onboarding, even though update-available actions stay hidden there.
|
||||
@@ -48,5 +49,6 @@ export function renderFloatingUpdateCard(params: {
|
||||
.refreshRequired=${params.refreshRequired}
|
||||
.onRefresh=${params.onRefresh}
|
||||
.onHoldUpdate=${params.onHoldUpdate ?? (async () => false)}
|
||||
.onReviewUpdate=${params.onReviewUpdate ?? (() => undefined)}
|
||||
></openclaw-sidebar-update-card>`;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
|
||||
import type { DevicePairSetupAccess, DevicePairSetupLifecycle } from "../lib/device-pair-setup.ts";
|
||||
import type { ExecApprovalDecision, ExecApprovalRequest } from "./exec-approval.ts";
|
||||
import type { ApplicationStatusBanner } from "./update-overlay-helpers.ts";
|
||||
import type { ApplicationStatusBanner, RecordedUpdateAttempt } from "./update-overlay-helpers.ts";
|
||||
|
||||
export type ApplicationOverlaySnapshot = {
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
updateSchedule: UpdateScheduleState | null;
|
||||
heldUpdateCampaignId: string | null;
|
||||
updateRunning: boolean;
|
||||
updateStatusRefreshing: boolean;
|
||||
updateReconciliationPending: boolean;
|
||||
updateStatusBanner: ApplicationStatusBanner | null;
|
||||
recordedUpdateAttempt: RecordedUpdateAttempt | null;
|
||||
controlUiRefreshRequired: boolean;
|
||||
approvalQueue: readonly ExecApprovalRequest[];
|
||||
approvalBusy: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ApplicationGatewaySnapshot } from "./gateway.ts";
|
||||
import {
|
||||
client,
|
||||
createGatewayHarness,
|
||||
deferred,
|
||||
flushMicrotasks,
|
||||
type RequestFn,
|
||||
} from "./overlays-access.test-support.ts";
|
||||
@@ -49,6 +50,33 @@ describe("application update campaign overlays", () => {
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("publishes pending and error state when a manual status refresh fails", async () => {
|
||||
const updateStatus = deferred();
|
||||
const request = vi.fn<RequestFn>((method) =>
|
||||
method === "update.status" ? updateStatus.promise : Promise.resolve({}),
|
||||
);
|
||||
const harness = createGatewayHarness(client(request));
|
||||
harness.update({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
const overlays = createApplicationOverlays(harness.gateway);
|
||||
|
||||
const refresh = overlays.refreshUpdateStatus();
|
||||
expect(overlays.snapshot.updateStatusRefreshing).toBe(true);
|
||||
|
||||
updateStatus.reject(new Error("Gateway unavailable"));
|
||||
await refresh;
|
||||
|
||||
expect(overlays.snapshot.updateStatusRefreshing).toBe(false);
|
||||
expect(overlays.snapshot.updateStatusBanner).toEqual({
|
||||
tone: "danger",
|
||||
text: expect.stringContaining("Gateway unavailable"),
|
||||
});
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("hydrates campaign state from hello and update.available events", () => {
|
||||
const harness = createGatewayHarness(client(async () => ({})));
|
||||
harness.update({
|
||||
|
||||
+39
-2
@@ -81,8 +81,10 @@ export function createApplicationOverlays(
|
||||
updateSchedule: null,
|
||||
heldUpdateCampaignId: null,
|
||||
updateRunning: false,
|
||||
updateStatusRefreshing: false,
|
||||
updateReconciliationPending: false,
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
controlUiRefreshRequired: false,
|
||||
approvalQueue: [],
|
||||
approvalBusy: false,
|
||||
@@ -177,6 +179,12 @@ export function createApplicationOverlays(
|
||||
snapshot = { ...snapshot, updateStatusBanner };
|
||||
publish();
|
||||
};
|
||||
const publishRecordedUpdateAttempt = (
|
||||
recordedUpdateAttempt: ApplicationOverlaySnapshot["recordedUpdateAttempt"],
|
||||
) => {
|
||||
snapshot = { ...snapshot, recordedUpdateAttempt };
|
||||
publish();
|
||||
};
|
||||
const heldCampaignId = (schedule: UpdateScheduleState | null) =>
|
||||
schedule?.campaign?.holdUntilMs !== undefined
|
||||
? schedule.campaign.id
|
||||
@@ -190,6 +198,13 @@ export function createApplicationOverlays(
|
||||
getHello: () => gateway.snapshot.hello,
|
||||
publish,
|
||||
publishBanner: publishUpdateBanner,
|
||||
publishRecordedAttempt: publishRecordedUpdateAttempt,
|
||||
publishRecordedFailure: ({ attempt, banner }) => {
|
||||
// Both facts terminate the same reconciliation. Publishing either first
|
||||
// exposes a false success or a failure without its recorded cause.
|
||||
snapshot = { ...snapshot, recordedUpdateAttempt: attempt, updateStatusBanner: banner };
|
||||
publish();
|
||||
},
|
||||
onVerifiedInstall: announceVerifiedUpdateInstall,
|
||||
});
|
||||
const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => {
|
||||
@@ -197,6 +212,7 @@ export function createApplicationOverlays(
|
||||
...snapshot,
|
||||
...projectUpdateStatusResponse(response, {
|
||||
updateStatusBanner: snapshot.updateStatusBanner,
|
||||
recordedUpdateAttempt: snapshot.recordedUpdateAttempt,
|
||||
heldUpdateCampaignId: snapshot.heldUpdateCampaignId,
|
||||
}),
|
||||
};
|
||||
@@ -215,7 +231,17 @@ export function createApplicationOverlays(
|
||||
getEpoch: () => connectedEpoch,
|
||||
canRefresh: () => operatorAccess.canAdmin,
|
||||
isCurrent: (client, epoch) => epoch === connectedEpoch && isCurrentClient(client),
|
||||
onRefreshing: (updateStatusRefreshing) => {
|
||||
snapshot = { ...snapshot, updateStatusRefreshing };
|
||||
publish();
|
||||
},
|
||||
onStatus: applyUpdateStatusResponse,
|
||||
onError: (error) => {
|
||||
publishUpdateBanner({
|
||||
tone: "danger",
|
||||
text: t("updates.error", { error: formatUiError(error) }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => {
|
||||
@@ -250,7 +276,12 @@ export function createApplicationOverlays(
|
||||
? resolveUnknownUpdateOutcomeBanner()
|
||||
: snapshot.updateStatusBanner;
|
||||
pendingUpdate = null;
|
||||
snapshot = { ...snapshot, updateRunning: false, updateStatusBanner };
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
updateRunning: false,
|
||||
updateStatusRefreshing: false,
|
||||
updateStatusBanner,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (accessTransition.pairingChanged) {
|
||||
@@ -289,6 +320,7 @@ export function createApplicationOverlays(
|
||||
updateAvailable: null,
|
||||
updateSchedule: null,
|
||||
updateRunning: false,
|
||||
updateStatusRefreshing: false,
|
||||
};
|
||||
updateCampaignPoller.stop();
|
||||
if (next.phase === "reload-required") {
|
||||
@@ -438,7 +470,12 @@ export function createApplicationOverlays(
|
||||
return;
|
||||
}
|
||||
const generation = ++updateRunGeneration;
|
||||
snapshot = { ...snapshot, updateRunning: true, updateStatusBanner: null };
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
updateRunning: true,
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
};
|
||||
publish();
|
||||
try {
|
||||
// updateRunning above suspends NEW config writes (bootstrap syncs it
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import {
|
||||
createUpdateVerificationController,
|
||||
formatUpdateCampaignLabel,
|
||||
projectUpdateStatusResponse,
|
||||
resolveUpdateStatusBanner,
|
||||
} from "./update-overlay-helpers.ts";
|
||||
import {
|
||||
@@ -88,6 +89,9 @@ async function verifyUpdate(params: {
|
||||
publishBanner: (value) => {
|
||||
banner = value;
|
||||
},
|
||||
publishRecordedFailure: ({ banner: value }) => {
|
||||
banner = value;
|
||||
},
|
||||
...(params.onVerifiedInstall ? { onVerifiedInstall: params.onVerifiedInstall } : {}),
|
||||
});
|
||||
|
||||
@@ -412,6 +416,48 @@ describe("update schedule hydration", () => {
|
||||
});
|
||||
|
||||
describe("update status localization", () => {
|
||||
it("projects the recorded update attempt without inferring from localized text", () => {
|
||||
installTranslations();
|
||||
const projected = projectUpdateStatusResponse(
|
||||
{
|
||||
sentinel: {
|
||||
kind: "update",
|
||||
status: "error",
|
||||
ts: 123,
|
||||
stats: {
|
||||
mode: "git",
|
||||
reason: "build-failed",
|
||||
before: { sha: "before" },
|
||||
after: { sha: "after" },
|
||||
steps: [
|
||||
{
|
||||
name: "build",
|
||||
log: { exitCode: 1, stderrTail: "first line\nType check failed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
updateStatusBanner: null,
|
||||
recordedUpdateAttempt: null,
|
||||
heldUpdateCampaignId: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(projected.recordedUpdateAttempt).toEqual({
|
||||
timestampMs: 123,
|
||||
status: "error",
|
||||
reason: "build-failed",
|
||||
installKind: "git",
|
||||
installedVersion: null,
|
||||
installedSha: "before",
|
||||
targetVersion: null,
|
||||
targetSha: "after",
|
||||
failure: { step: "build", detail: "Type check failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("localizes known update failure guidance", () => {
|
||||
const translate = installTranslations();
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ export type ApplicationStatusBanner = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RecordedUpdateAttempt = {
|
||||
timestampMs: number;
|
||||
status: string;
|
||||
reason: string;
|
||||
installKind: string | null;
|
||||
installedVersion: string | null;
|
||||
installedSha: string | null;
|
||||
targetVersion: string | null;
|
||||
targetSha: string | null;
|
||||
failure: UpdateFailureCause | null;
|
||||
};
|
||||
|
||||
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
|
||||
const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending";
|
||||
const UPDATE_RESTART_VERIFICATION_POLL_MS = 250;
|
||||
@@ -63,8 +75,11 @@ export type UpdateRestartStatusResponse = {
|
||||
sentinel?: {
|
||||
kind?: string;
|
||||
status?: string;
|
||||
ts?: number;
|
||||
stats?: {
|
||||
mode?: string | null;
|
||||
reason?: string | null;
|
||||
before?: { sha?: string | null; version?: string | null } | null;
|
||||
after?: { sha?: string | null; version?: string | null } | null;
|
||||
steps?: UpdateSentinelStep[] | null;
|
||||
} | null;
|
||||
@@ -75,6 +90,32 @@ export type UpdateRestartStatusResponse = {
|
||||
|
||||
type UpdateFailureCause = { step: string; detail: string };
|
||||
|
||||
function readRecordedUpdateAttempt(
|
||||
sentinel: UpdateRestartStatusResponse["sentinel"],
|
||||
): RecordedUpdateAttempt | null {
|
||||
if (
|
||||
sentinel?.kind !== "update" ||
|
||||
!sentinel.status ||
|
||||
sentinel.status === "ok" ||
|
||||
isPendingUpdateHandoffSentinel(sentinel) ||
|
||||
typeof sentinel.ts !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const stats = sentinel.stats;
|
||||
return {
|
||||
timestampMs: sentinel.ts,
|
||||
status: sentinel.status,
|
||||
reason: stats?.reason?.trim() || "unexpected-error",
|
||||
installKind: stats?.mode?.trim() || null,
|
||||
installedVersion: stats?.before?.version?.trim() || null,
|
||||
installedSha: stats?.before?.sha?.trim() || null,
|
||||
targetVersion: stats?.after?.version?.trim() || null,
|
||||
targetSha: stats?.after?.sha?.trim() || null,
|
||||
failure: readUpdateFailureCause(sentinel),
|
||||
};
|
||||
}
|
||||
|
||||
function lastLogLine(tail: string | null | undefined): string | null {
|
||||
const lines = (tail ?? "")
|
||||
.split("\n")
|
||||
@@ -118,12 +159,14 @@ async function requestUpdateRestartStatus(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
timeoutMs: number,
|
||||
request: { refreshCheckout?: true } = {},
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<UpdateRestartStatusResponse | null> {
|
||||
try {
|
||||
return await client.request<UpdateRestartStatusResponse>("update.status", request, {
|
||||
timeoutMs,
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -133,17 +176,38 @@ export function createUpdateStatusRefresher(params: {
|
||||
getEpoch: () => number;
|
||||
canRefresh: () => boolean;
|
||||
isCurrent: (client: GatewayBrowserClient, epoch: number) => boolean;
|
||||
onRefreshing: (refreshing: boolean) => void;
|
||||
onStatus: (response: UpdateRestartStatusResponse) => void;
|
||||
onError: (error: unknown) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return async () => {
|
||||
const client = params.getClient();
|
||||
const epoch = params.getEpoch();
|
||||
if (!client || !params.canRefresh()) {
|
||||
return;
|
||||
}
|
||||
const response = await requestUpdateRestartStatus(client, 5_000, { refreshCheckout: true });
|
||||
if (response && params.isCurrent(client, epoch)) {
|
||||
params.onStatus(response);
|
||||
const operationGeneration = ++generation;
|
||||
const isCurrent = () => operationGeneration === generation && params.isCurrent(client, epoch);
|
||||
params.onRefreshing(true);
|
||||
try {
|
||||
const response = await requestUpdateRestartStatus(
|
||||
client,
|
||||
5_000,
|
||||
{ refreshCheckout: true },
|
||||
(error) => {
|
||||
if (isCurrent()) {
|
||||
params.onError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (response && isCurrent()) {
|
||||
params.onStatus(response);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
params.onRefreshing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -225,6 +289,11 @@ export function createUpdateVerificationController(params: {
|
||||
getHello: () => GatewayHelloOk | null;
|
||||
publish: () => void;
|
||||
publishBanner: (banner: ApplicationStatusBanner | null) => void;
|
||||
publishRecordedAttempt?: (attempt: RecordedUpdateAttempt | null) => void;
|
||||
publishRecordedFailure: (params: {
|
||||
attempt: RecordedUpdateAttempt | null;
|
||||
banner: ApplicationStatusBanner;
|
||||
}) => void;
|
||||
onVerifiedInstall?: (identity: { version: string | null; sha: string | null }) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
@@ -289,13 +358,14 @@ export function createUpdateVerificationController(params: {
|
||||
}
|
||||
if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") {
|
||||
params.clearPending();
|
||||
params.publishBanner(
|
||||
resolveUpdateStatusBanner({
|
||||
params.publishRecordedFailure({
|
||||
attempt: readRecordedUpdateAttempt(sentinel),
|
||||
banner: resolveUpdateStatusBanner({
|
||||
status: "error",
|
||||
...(sentinel.stats?.reason ? { reason: sentinel.stats.reason } : {}),
|
||||
cause: readUpdateFailureCause(sentinel),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const actualVersion = sentinel?.stats?.after?.version?.trim() || null;
|
||||
@@ -308,6 +378,7 @@ export function createUpdateVerificationController(params: {
|
||||
const hasActualIdentity = actualVersion !== null || actualSha !== null;
|
||||
if (versionMatches && shaMatches && (hasActualIdentity || !hasExpectedIdentity)) {
|
||||
params.clearPending();
|
||||
params.publishRecordedAttempt?.(null);
|
||||
params.onVerifiedInstall?.({ version: actualVersion, sha: actualSha });
|
||||
params.publishBanner(null);
|
||||
return;
|
||||
@@ -420,10 +491,12 @@ export function projectUpdateStatusResponse(
|
||||
response: UpdateRestartStatusResponse,
|
||||
current: {
|
||||
updateStatusBanner: ApplicationStatusBanner | null;
|
||||
recordedUpdateAttempt: RecordedUpdateAttempt | null;
|
||||
heldUpdateCampaignId: string | null;
|
||||
},
|
||||
): {
|
||||
updateStatusBanner: ApplicationStatusBanner | null;
|
||||
recordedUpdateAttempt: RecordedUpdateAttempt | null;
|
||||
updateAvailable?: UpdateAvailable | null;
|
||||
updateSchedule?: UpdateScheduleState | null;
|
||||
heldUpdateCampaignId?: string | null;
|
||||
@@ -443,6 +516,10 @@ export function projectUpdateStatusResponse(
|
||||
cause: readUpdateFailureCause(sentinel),
|
||||
})
|
||||
: current.updateStatusBanner,
|
||||
recordedUpdateAttempt:
|
||||
sentinel?.kind === "update" && sentinel.status
|
||||
? readRecordedUpdateAttempt(sentinel)
|
||||
: current.recordedUpdateAttempt,
|
||||
...(Object.hasOwn(response, "updateAvailable")
|
||||
? { updateAvailable: readUpdateAvailableValue(response.updateAvailable) }
|
||||
: {}),
|
||||
|
||||
@@ -63,6 +63,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) refreshRequired = false;
|
||||
@property({ attribute: false }) onRefresh: () => void = () => undefined;
|
||||
@property({ attribute: false }) onHoldUpdate: () => Promise<boolean> = async () => false;
|
||||
@property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
|
||||
@property({ attribute: false }) onOpenApprovals?: () => void;
|
||||
@property({ attribute: false }) onRetryConnect?: () => void;
|
||||
@property({ attribute: false }) onOpenNewSession?: (
|
||||
|
||||
@@ -572,6 +572,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
.refreshRequired=${this.refreshRequired}
|
||||
.onRefresh=${this.onRefresh}
|
||||
.onHoldUpdate=${this.onHoldUpdate}
|
||||
.onReviewUpdate=${this.onReviewUpdate}
|
||||
></openclaw-sidebar-update-card>
|
||||
<openclaw-lobster-pet
|
||||
.seed=${lobsterPetSeed(this.sessionKey)}
|
||||
|
||||
@@ -51,6 +51,7 @@ type SettingsSidebarProps = {
|
||||
refreshRequired: boolean;
|
||||
onRefresh: () => void;
|
||||
onHoldUpdate?: () => Promise<boolean>;
|
||||
onReviewUpdate?: () => void;
|
||||
searchQuery: string;
|
||||
searchBlockMatches?: readonly SettingsSearchBlock[];
|
||||
onExit: () => void;
|
||||
@@ -325,6 +326,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
|
||||
.refreshRequired=${props.refreshRequired}
|
||||
.onRefresh=${props.onRefresh}
|
||||
.onHoldUpdate=${props.onHoldUpdate ?? (async () => false)}
|
||||
.onReviewUpdate=${props.onReviewUpdate ?? (() => undefined)}
|
||||
></openclaw-sidebar-update-card>
|
||||
<footer class="settings-sidebar__footer">
|
||||
${props.offline
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
NATIVE_UPDATE_DECLINED_EVENT,
|
||||
} from "../app/native-link-routing.ts";
|
||||
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
|
||||
import {
|
||||
answerConfirmDialog,
|
||||
cancelOpenModalDialogs,
|
||||
@@ -37,6 +38,8 @@ type SidebarUpdateCardElement = HTMLElement & {
|
||||
refreshRequired: boolean;
|
||||
onRefresh: () => void;
|
||||
onHoldUpdate: () => Promise<boolean>;
|
||||
statusBanner: ApplicationStatusBanner | null;
|
||||
onReviewUpdate: () => void;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -205,6 +208,18 @@ describe("SidebarUpdateCard", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("routes a recorded failure to update settings when availability is gone", async () => {
|
||||
const element = await mount(null);
|
||||
const onReviewUpdate = vi.fn();
|
||||
element.statusBanner = { tone: "danger", text: "Update failed" };
|
||||
element.onReviewUpdate = onReviewUpdate;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.textContent).toContain("Update failed");
|
||||
element.querySelector<HTMLButtonElement>(".sidebar-update-card__review")?.click();
|
||||
expect(onReviewUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing for a dismissed version and channel", async () => {
|
||||
localStorage.setItem(
|
||||
DISMISS_KEY,
|
||||
|
||||
@@ -73,6 +73,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) refreshRequired = false;
|
||||
@property({ attribute: false }) onRefresh: () => void = () => undefined;
|
||||
@property({ attribute: false }) onHoldUpdate: () => Promise<boolean> = async () => false;
|
||||
@property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
|
||||
@state() private dismissedUpdateKey: string | null = null;
|
||||
@state() private holdingCampaignId: string | null = null;
|
||||
@state() private nativeUpdateAvailable = hasNativeUpdateBridge();
|
||||
@@ -297,6 +298,15 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
${icons.x}
|
||||
</button>
|
||||
`}
|
||||
${statusBanner
|
||||
? html`<button
|
||||
class="sidebar-update-card__review"
|
||||
type="button"
|
||||
@click=${this.onReviewUpdate}
|
||||
>
|
||||
${t("updates.reviewUpdate")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -157,4 +157,60 @@ suite.define(() => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a failed manual status check visible", async () => {
|
||||
await suite.withPage(
|
||||
{
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
},
|
||||
async ({ page }) => {
|
||||
const config = { update: { auto: { enabled: false }, channel: "stable" } };
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["config.get", "update.run", "update.status"],
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
config,
|
||||
hash: "updates-status-error-1",
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
runtimeConfig: config,
|
||||
valid: true,
|
||||
},
|
||||
"update.status": {
|
||||
sentinel: {
|
||||
kind: "update",
|
||||
status: "error",
|
||||
ts: Date.now(),
|
||||
stats: { mode: "package", reason: "build-failed" },
|
||||
},
|
||||
},
|
||||
},
|
||||
operatorScopes: ["operator.read", "operator.admin"],
|
||||
});
|
||||
|
||||
expect((await page.goto(`${suite.server.baseUrl}settings/updates`))?.status()).toBe(200);
|
||||
await gateway.waitForRequest("update.status");
|
||||
const checkStatus = page.getByRole("button", { name: "Check status", exact: true });
|
||||
await checkStatus.waitFor();
|
||||
|
||||
await gateway.deferNext("update.status");
|
||||
await checkStatus.click();
|
||||
await expect.poll(async () => (await gateway.getRequests("update.status")).length).toBe(2);
|
||||
expect(await checkStatus.isDisabled()).toBe(true);
|
||||
|
||||
await gateway.rejectDeferred("update.status", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "Gateway status is temporarily unavailable",
|
||||
});
|
||||
await page
|
||||
.locator("#config-section-update .settings-status")
|
||||
.filter({ hasText: "Gateway status is temporarily unavailable" })
|
||||
.waitFor();
|
||||
expect(await checkStatus.isDisabled()).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+7
@@ -379,6 +379,13 @@
|
||||
"path": "ui/src/pages/config/mcp.ts",
|
||||
"text": "openclaw mcp status --verbose"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/config/updates.ts",
|
||||
"text": "openclaw update status --json openclaw update"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-attribute",
|
||||
|
||||
@@ -415,6 +415,7 @@ export const en: TranslationMap = {
|
||||
applying: "Applying update…",
|
||||
},
|
||||
holdOneHour: "Hold 1 h",
|
||||
reviewUpdate: "Review update",
|
||||
confirm: {
|
||||
message: "Installs the available update on the connected Gateway and restarts it.",
|
||||
macMessage:
|
||||
@@ -486,6 +487,20 @@ export const en: TranslationMap = {
|
||||
gitComparisonFailed: "Could not compare this checkout with its tracked upstream",
|
||||
updateNow: "Update now",
|
||||
updateNowDescription: "Install the available update and restart the Gateway.",
|
||||
latestAttempt: "Latest update attempt",
|
||||
attemptedAt: "Attempted",
|
||||
attemptTarget: "Target",
|
||||
installedIdentity: "Installed",
|
||||
attemptInstallKind: "Attempt install type",
|
||||
attemptReason: "Reason code",
|
||||
failedStep: "Failure details",
|
||||
viewDetails: "View details",
|
||||
recoveryActions: "Recovery",
|
||||
checkStatus: "Check status",
|
||||
retryUpdate: "Retry update",
|
||||
troubleshoot: "Troubleshoot updates",
|
||||
cliFallback: "CLI fallback",
|
||||
showCliFallback: "Show terminal commands",
|
||||
},
|
||||
coalescedRestart:
|
||||
"Update installed. A gateway restart is already in progress; status will refresh after it reconnects.",
|
||||
|
||||
@@ -1052,7 +1052,9 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
|
||||
private isUpdateBusy(): boolean {
|
||||
const update = this.context.overlays.snapshot;
|
||||
return update.updateRunning || update.updateReconciliationPending;
|
||||
return (
|
||||
update.updateRunning || update.updateStatusRefreshing || update.updateReconciliationPending
|
||||
);
|
||||
}
|
||||
|
||||
// The update dialog outlives this page and the connection, so it reads live
|
||||
@@ -1108,9 +1110,11 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
|
||||
updateAvailable: overlaySnapshot.updateAvailable,
|
||||
statusBanner: overlaySnapshot.updateStatusBanner,
|
||||
recordedAttempt: overlaySnapshot.recordedUpdateAttempt,
|
||||
configBusy: this.isCuratedConfigMutationDisabled(),
|
||||
canAdmin,
|
||||
canUpdate: canCallGatewayMethod(gatewaySnapshot, "update.run", "operator.admin"),
|
||||
canCheckStatus: canCallGatewayMethod(gatewaySnapshot, "update.status", "operator.admin"),
|
||||
canHoldUpdate: canCallGatewayMethod(gatewaySnapshot, "update.hold", "operator.admin"),
|
||||
updateBusy: this.isUpdateBusy(),
|
||||
onChannelChange: (channel) => runtimeConfig.patchForm(["update", "channel"], channel),
|
||||
@@ -1127,6 +1131,7 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
viaNativeApp: false,
|
||||
}),
|
||||
onHoldUpdate: () => this.context.overlays.holdUpdate(),
|
||||
onCheckStatus: () => this.context.overlays.refreshUpdateStatus(),
|
||||
});
|
||||
}
|
||||
const includeSections = this.includeSections();
|
||||
|
||||
@@ -29,9 +29,11 @@ function createProps(overrides: Partial<UpdatesViewProps> = {}): UpdatesViewProp
|
||||
channel: "stable",
|
||||
},
|
||||
statusBanner: null,
|
||||
recordedAttempt: null,
|
||||
configBusy: false,
|
||||
canAdmin: true,
|
||||
canUpdate: true,
|
||||
canCheckStatus: true,
|
||||
canHoldUpdate: true,
|
||||
updateBusy: false,
|
||||
nowMs: 1_000,
|
||||
@@ -39,6 +41,7 @@ function createProps(overrides: Partial<UpdatesViewProps> = {}): UpdatesViewProp
|
||||
onAutomaticUpdatesChange: vi.fn(),
|
||||
onUpdateNow: vi.fn(),
|
||||
onHoldUpdate: vi.fn(async () => true),
|
||||
onCheckStatus: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -453,6 +456,46 @@ describe("renderUpdates", () => {
|
||||
expect(row("Status").querySelector(".settings-status--danger")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the recorded failure details and typed recovery actions", () => {
|
||||
const onUpdateNow = vi.fn();
|
||||
const onCheckStatus = vi.fn(async () => undefined);
|
||||
render(
|
||||
renderUpdates(
|
||||
createProps({
|
||||
recordedAttempt: {
|
||||
timestampMs: 500,
|
||||
status: "error",
|
||||
reason: "build-failed",
|
||||
installKind: "git",
|
||||
installedVersion: null,
|
||||
installedSha: "0123456789abcdef",
|
||||
targetVersion: null,
|
||||
targetSha: null,
|
||||
failure: { step: "build", detail: "Type check failed" },
|
||||
},
|
||||
onUpdateNow,
|
||||
onCheckStatus,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(row("Reason code").textContent).toContain("build-failed");
|
||||
expect(row("Target").textContent).toContain("v2026.8.2");
|
||||
expect(row("Installed").textContent).toContain("0123456789ab");
|
||||
expect(row("Attempt install type").textContent).toContain("git");
|
||||
expect(row("Failure details").textContent).toContain("Type check failed");
|
||||
const recovery = row("Recovery");
|
||||
recovery.querySelector<HTMLButtonElement>("button")?.click();
|
||||
recovery.querySelectorAll<HTMLButtonElement>("button")[1]?.click();
|
||||
expect(onCheckStatus).toHaveBeenCalledOnce();
|
||||
expect(onUpdateNow).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
container.querySelector<HTMLAnchorElement>("a[href*='update-troubleshooting']"),
|
||||
).not.toBeNull();
|
||||
expect(row("CLI fallback").textContent).toContain("openclaw update status --json");
|
||||
});
|
||||
|
||||
it("keeps read-only facts visible while locking controls for non-admins", () => {
|
||||
render(
|
||||
renderUpdates(createProps({ canAdmin: false, canUpdate: false, configBusy: true })),
|
||||
|
||||
@@ -4,7 +4,10 @@ import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-
|
||||
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../../api/types.ts";
|
||||
import type { ApplicationStatusBanner } from "../../app/update-overlay-helpers.ts";
|
||||
import type {
|
||||
ApplicationStatusBanner,
|
||||
RecordedUpdateAttempt,
|
||||
} from "../../app/update-overlay-helpers.ts";
|
||||
import {
|
||||
formatUpdateCampaignLabel,
|
||||
formatUpdateTargetLabel,
|
||||
@@ -34,9 +37,11 @@ type UpdatesViewProps = {
|
||||
heldUpdateCampaignId: string | null;
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
statusBanner: ApplicationStatusBanner | null;
|
||||
recordedAttempt: RecordedUpdateAttempt | null;
|
||||
configBusy: boolean;
|
||||
canAdmin: boolean;
|
||||
canUpdate: boolean;
|
||||
canCheckStatus: boolean;
|
||||
canHoldUpdate: boolean;
|
||||
updateBusy: boolean;
|
||||
nowMs?: number;
|
||||
@@ -44,8 +49,104 @@ type UpdatesViewProps = {
|
||||
onAutomaticUpdatesChange: (enabled: boolean) => void;
|
||||
onUpdateNow: () => void;
|
||||
onHoldUpdate: () => Promise<boolean>;
|
||||
onCheckStatus: () => Promise<void>;
|
||||
};
|
||||
|
||||
function formatAttemptIdentity(version: string | null, sha: string | null): string {
|
||||
return version ? `v${version}` : sha ? sha.slice(0, 12) : t("common.unknown");
|
||||
}
|
||||
|
||||
function renderRecordedAttempt(props: UpdatesViewProps) {
|
||||
const attempt = props.recordedAttempt;
|
||||
if (!attempt && !props.statusBanner) {
|
||||
return nothing;
|
||||
}
|
||||
const canRetry = props.canUpdate && !props.updateBusy;
|
||||
const recordedTarget = attempt
|
||||
? formatAttemptIdentity(attempt.targetVersion, attempt.targetSha)
|
||||
: null;
|
||||
const target =
|
||||
recordedTarget && recordedTarget !== t("common.unknown")
|
||||
? recordedTarget
|
||||
: (formatUpdateTargetLabel(props.schedule, props.updateAvailable) ?? t("common.unknown"));
|
||||
return renderSettingsSection({ title: t("updates.page.latestAttempt") }, [
|
||||
attempt
|
||||
? renderSettingsRow({
|
||||
title: t("updates.page.attemptedAt"),
|
||||
control: renderTimestamp(attempt.timestampMs, props.nowMs),
|
||||
})
|
||||
: nothing,
|
||||
renderSettingsRow({
|
||||
title: t("updates.page.attemptTarget"),
|
||||
control: renderSettingsValue(target, { mono: true }),
|
||||
}),
|
||||
attempt
|
||||
? renderSettingsRow({
|
||||
title: t("updates.page.installedIdentity"),
|
||||
control: renderSettingsValue(
|
||||
formatAttemptIdentity(attempt.installedVersion, attempt.installedSha),
|
||||
{ mono: true },
|
||||
),
|
||||
})
|
||||
: nothing,
|
||||
attempt?.installKind
|
||||
? renderSettingsRow({
|
||||
title: t("updates.page.attemptInstallKind"),
|
||||
control: renderSettingsValue(attempt.installKind),
|
||||
})
|
||||
: nothing,
|
||||
attempt
|
||||
? renderSettingsRow({
|
||||
title: t("updates.page.attemptReason"),
|
||||
control: renderSettingsValue(html`<code>${attempt.reason}</code>`, { mono: true }),
|
||||
})
|
||||
: nothing,
|
||||
attempt?.failure
|
||||
? renderSettingsRow({
|
||||
title: t("updates.page.failedStep"),
|
||||
stacked: true,
|
||||
control: html`<details class="updates-attempt-details">
|
||||
<summary>${t("updates.page.viewDetails")}</summary>
|
||||
<div><code>${attempt.failure.step}</code></div>
|
||||
<pre>${attempt.failure.detail}</pre>
|
||||
</details>`,
|
||||
})
|
||||
: nothing,
|
||||
renderSettingsRow({
|
||||
title: t("updates.page.recoveryActions"),
|
||||
control: html`<div class="updates-status-control">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
type="button"
|
||||
title=${props.canCheckStatus ? "" : t("updates.adminRequired")}
|
||||
?disabled=${!props.canCheckStatus || props.updateBusy}
|
||||
@click=${() => void props.onCheckStatus()}
|
||||
>
|
||||
${t("updates.page.checkStatus")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
type="button"
|
||||
title=${canRetry ? "" : t("updates.adminRequired")}
|
||||
?disabled=${!canRetry}
|
||||
@click=${props.onUpdateNow}
|
||||
>
|
||||
${t("updates.page.retryUpdate")}
|
||||
</button>
|
||||
</div>`,
|
||||
}),
|
||||
renderSettingsRow({
|
||||
title: t("updates.page.cliFallback"),
|
||||
stacked: true,
|
||||
control: html`<details class="updates-attempt-details">
|
||||
<summary>${t("updates.page.showCliFallback")}</summary>
|
||||
<pre><code>openclaw update status --json
|
||||
openclaw update</code></pre>
|
||||
</details>`,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function readUpdatesSettings(
|
||||
configObject: Record<string, unknown>,
|
||||
schedule: UpdateScheduleState | null,
|
||||
@@ -311,6 +412,7 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult {
|
||||
? html`<div class="callout warning" role="note">${t("updates.adminRequired")}</div>`
|
||||
: nothing,
|
||||
renderBuildFacts(props),
|
||||
renderRecordedAttempt(props),
|
||||
renderSettingsSection({ title: t("updates.page.policyTitle") }, policyRows),
|
||||
renderSettingsSection({ title: t("updates.page.statusTitle") }, [
|
||||
renderSettingsRow({
|
||||
@@ -351,6 +453,11 @@ export function renderUpdates(props: UpdatesViewProps): TemplateResult {
|
||||
`,
|
||||
}),
|
||||
]),
|
||||
html`<p class="settings-page__hint">
|
||||
<a href="https://docs.openclaw.ai/install/update-troubleshooting" target="_blank"
|
||||
>${t("updates.page.troubleshoot")}</a
|
||||
>
|
||||
</p>`,
|
||||
],
|
||||
{ intro: t("updates.page.intro") },
|
||||
)}
|
||||
|
||||
@@ -1285,6 +1285,23 @@ body.update-dialog-open .sidebar-update-card__status {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sidebar-update-card__review {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
cursor: var(--cursor-action);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-update-card__review:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sidebar-update-card__status--danger {
|
||||
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
|
||||
@@ -595,6 +595,26 @@ textarea.settings-input {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.updates-attempt-details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.updates-attempt-details summary {
|
||||
cursor: var(--cursor-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.updates-attempt-details pre {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.updates-commit-list {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user