feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look". Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless. POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process. The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard. Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist. Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
This commit is contained in:
@@ -353,15 +353,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
};
|
||||
|
||||
const updateAvailable = Boolean(updateStatus?.update_available);
|
||||
// Four resting states, not two: a release is out, this build is ahead of the
|
||||
// newest release (dev build), it matches the newest release, or no release
|
||||
// could be read at all. Collapsing the middle two into "Up to date" is what
|
||||
// made a checkout carrying unreleased commits look shipped.
|
||||
const checkTitle = checking
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? t("nav:updateAvailable")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* @file UpdateNotifier.tsx
|
||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||
* fast-forwardable branch — an "Update now" button that calls
|
||||
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||
* then polls until it's back and reloads the page.
|
||||
* @description Modal surfaced when a newer RELEASE of the dashboard has been
|
||||
* published (or, when no release can be read, when the checkout is behind its
|
||||
* remote tracking branch). Shows the release version against the running one
|
||||
* with a link to its notes, the exact terminal command with copy-to-clipboard,
|
||||
* and — when the install is fast-forwardable — an "Update now" button that
|
||||
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
|
||||
* restart the server itself, then polls until it's back and reloads the page.
|
||||
*
|
||||
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
|
||||
* the same button, wired to a different mechanism: the server hands it to a
|
||||
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
|
||||
* which pulls the new cache directory, reinstalls the runtime and restarts the
|
||||
* dashboard. It takes minutes rather than seconds, so the progress text differs
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -14,8 +25,9 @@
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
||||
* commit re-opens the prompt. Settings can reset dismissal via the
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -66,21 +78,29 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed upstream SHA. */
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** How often to silently re-check for updates in the background. */
|
||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
|
||||
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
|
||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
* `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`),
|
||||
* which is why it belongs here even though `applyUpdate` itself refuses it. */
|
||||
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
|
||||
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
|
||||
return (
|
||||
situation === "plugin_managed" ||
|
||||
situation === "release_available" ||
|
||||
situation === "tracking_canonical" ||
|
||||
situation === "fork_or_diverged_tracking"
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||
@@ -88,7 +108,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||
}
|
||||
|
||||
/** Read the last dismissed upstream SHA from `localStorage`, or null. */
|
||||
/** Read the last dismissed release tag / upstream SHA from `localStorage`. */
|
||||
function loadDismissedSha(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY);
|
||||
@@ -99,7 +119,7 @@ function loadDismissedSha(): string | null {
|
||||
|
||||
/**
|
||||
* Git update availability modal — mounted once in {@link Layout}.
|
||||
* @returns `null` when no update is available or the current SHA was dismissed.
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
@@ -161,19 +181,22 @@ export function UpdateNotifier() {
|
||||
return () => window.removeEventListener("dashboard:reset-update-dismissal", handler);
|
||||
}, []);
|
||||
|
||||
const show = Boolean(
|
||||
status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha
|
||||
);
|
||||
// Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't
|
||||
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
|
||||
// commit the user already has. Falls back to the upstream SHA for the
|
||||
// commit-count path (no release readable).
|
||||
const dismissKey = status?.latest_release || status?.remote_sha || null;
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (restarting || !status?.remote_sha) return;
|
||||
if (restarting || !dismissKey) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, status.remote_sha);
|
||||
localStorage.setItem(DISMISS_KEY, dismissKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(status.remote_sha);
|
||||
}, [restarting, status?.remote_sha]);
|
||||
setDismissedSha(dismissKey);
|
||||
}, [restarting, dismissKey]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
@@ -213,12 +236,27 @@ export function UpdateNotifier() {
|
||||
// Polls `status()` until the restarted server answers again, then reloads
|
||||
// so the tab picks up the new client bundle too — the server can't push
|
||||
// this over its own WebSocket since it's mid-restart.
|
||||
const pollUntilBack = useCallback(() => {
|
||||
/**
|
||||
* @param requireDownFirst wait for the server to actually disappear before
|
||||
* treating a success as "it's back". The git path rebuilds and restarts
|
||||
* within seconds, but the plugin chain pulls a new cache directory and
|
||||
* reinstalls dependencies first — the OLD server stays up for that whole
|
||||
* stretch, so polling naively would get a 200 from the process we are
|
||||
* waiting to replace and reload onto the same old bundle.
|
||||
*/
|
||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(() => window.location.reload())
|
||||
.catch(() => setTimeout(attempt, 1500));
|
||||
.then(() => {
|
||||
if (sawDown) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
}, []);
|
||||
@@ -232,11 +270,17 @@ export function UpdateNotifier() {
|
||||
if (result.applied) {
|
||||
setApplying(false);
|
||||
setRestarting(true);
|
||||
pollUntilBack();
|
||||
// mode "plugin": the old server lingers while the chain installs, so
|
||||
// only treat a reachable server as "back" after it has gone away once.
|
||||
pollUntilBack(result.mode === "plugin");
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
|
||||
result.reason === "plugin_managed"
|
||||
? t("reasonPluginManaged")
|
||||
: result.reason === "not_fast_forwardable"
|
||||
? t("reasonNotFastForwardable")
|
||||
: t("applyError")
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("applyError"));
|
||||
@@ -249,6 +293,8 @@ export function UpdateNotifier() {
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
const behind = status.commits_behind ?? 0;
|
||||
const release = status.latest_release || null;
|
||||
const pluginManaged = status.situation === "plugin_managed";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -272,10 +318,14 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{t("title")}
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
{/* Never print a commit count on a shallow clone — the number
|
||||
is a graft artefact, not a distance. */}
|
||||
{release && !status.shallow_clone && status.local_version
|
||||
? t("releaseSubtitle", { current: status.local_version })
|
||||
: t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +341,30 @@ export function UpdateNotifier() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
{status.latest_release_url ? (
|
||||
<a
|
||||
href={status.latest_release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("viewRelease")}
|
||||
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Release lookup failed and detection fell back to commit counting:
|
||||
say so, so "up to date" is never read as "release checked". */}
|
||||
{status.release_check && status.release_check !== "ok" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("releaseUnavailable", { reason: status.release_check })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.fetch_error ? (
|
||||
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
|
||||
@@ -323,7 +396,8 @@ export function UpdateNotifier() {
|
||||
{/* The restart hint only applies when the printed command actually
|
||||
* rewrites the working tree. Feature-branch / detached-HEAD commands
|
||||
* are fetch-only - restarting the dashboard would change nothing. */}
|
||||
{status.situation === "tracking_canonical" ||
|
||||
{status.situation === "release_available" ||
|
||||
status.situation === "tracking_canonical" ||
|
||||
status.situation === "fork_or_diverged_tracking" ? (
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||
) : null}
|
||||
@@ -331,7 +405,22 @@ export function UpdateNotifier() {
|
||||
{restarting ? (
|
||||
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||
{t("restarting")}
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* A previous chain that failed: the only place the user would ever
|
||||
learn why, since the server it would have reported from is the one
|
||||
that never came back. */}
|
||||
{status.last_plugin_update && status.last_plugin_update.ok === false ? (
|
||||
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||
{t("lastPluginUpdateFailed", {
|
||||
step: status.last_plugin_update.step,
|
||||
error: status.last_plugin_update.error || "",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Sidebar", () => {
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument();
|
||||
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
|
||||
@@ -90,6 +90,72 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => {
|
||||
// The button works here via the detached `claude plugin update` chain — but
|
||||
// it moves only the dashboard server, so the note about /reload-plugins is
|
||||
// part of the promise, not decoration.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
shallow_clone: true,
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
last_plugin_update: {
|
||||
ok: false,
|
||||
step: "resolve_claude",
|
||||
error: "the `claude` CLI is not on this server's PATH",
|
||||
finished_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
expect(screen.getByText(/resolve_claude/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("titles the modal with the release version when one was read", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "release_available",
|
||||
release_check: "ok",
|
||||
local_version: "1.5.0",
|
||||
latest_release: "v1.6.0",
|
||||
commits_behind_release: 3,
|
||||
commits_ahead_of_release: 0,
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }));
|
||||
// A release IS fast-forwardable on a checkout, so the button stays.
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||
applyMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "Agent Dashboard",
|
||||
"brandSub": "Claude Code Monitor",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Claude Config",
|
||||
"checkFailed": "Update check failed",
|
||||
"checkForUpdates": "Check for updates",
|
||||
@@ -65,5 +65,7 @@
|
||||
"website": "Website",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Workspace",
|
||||
"wsEndpoint": "WebSocket"
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "{{version}} available",
|
||||
"devBuild": "Dev build · {{count}} ahead"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tagline": "Nothing your agents do stays in the dark.",
|
||||
"sub1": "Real-time observability for every Claude Code session.",
|
||||
"sub2": "Tokens, tools, costs, and workflows, all live.",
|
||||
"brand": "Claude Code Monitor",
|
||||
"brand": "SmartGift Solution",
|
||||
"taglines": [
|
||||
"Nothing your agents do stays in the dark.",
|
||||
"See every move your agents make.",
|
||||
|
||||
@@ -16,5 +16,14 @@
|
||||
"updating": "Updating...",
|
||||
"restarting": "Rebuilt — restarting the dashboard...",
|
||||
"applyError": "Could not apply the update",
|
||||
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
|
||||
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually.",
|
||||
"releaseTitle": "Release {{version}} available",
|
||||
"releaseSubtitle": "You are running {{current}}",
|
||||
"releaseLead": "A new release of the dashboard has been published. \"Update now\" fast-forwards this install onto the release tag, reinstalls dependencies, rebuilds, and restarts the server.",
|
||||
"viewRelease": "Release notes",
|
||||
"reasonPluginManaged": "This install is managed by the Claude Code plugin cache — run /agenthub-update instead.",
|
||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover."
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
"brand": "Bảng điều khiển Agent",
|
||||
"brandSub": "Giám sát Claude Code",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Cấu hình Claude",
|
||||
"checkFailed": "Kiểm tra cập nhật thất bại",
|
||||
"checkForUpdates": "Kiểm tra cập nhật",
|
||||
@@ -65,5 +65,7 @@
|
||||
"website": "Website",
|
||||
"workflows": "Quy trình",
|
||||
"workspace": "Không gian làm việc",
|
||||
"wsEndpoint": "WebSocket"
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
"devBuild": "Bản dev · trước {{count}} commit"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tagline": "Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
|
||||
"sub1": "Khả năng quan sát thời gian thực cho mọi phiên Claude Code.",
|
||||
"sub2": "Token, công cụ, chi phí và workflow, tất cả trực tiếp.",
|
||||
"brand": "Giám sát Claude Code",
|
||||
"brand": "SmartGift Solution",
|
||||
"taglines": [
|
||||
"Mọi điều agent của bạn làm đều không ẩn trong bóng tối.",
|
||||
"Thấy rõ từng bước đi của agent.",
|
||||
|
||||
@@ -16,5 +16,14 @@
|
||||
"updating": "Đang cập nhật...",
|
||||
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
|
||||
"applyError": "Không thể áp dụng cập nhật",
|
||||
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
|
||||
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công.",
|
||||
"releaseTitle": "Đã có bản phát hành {{version}}",
|
||||
"releaseSubtitle": "Bạn đang chạy {{current}}",
|
||||
"releaseLead": "Repo vừa phát hành bản mới. Bấm \"Cập nhật ngay\" để fast-forward install này lên tag của bản phát hành, cài lại dependency, build lại và khởi động lại server.",
|
||||
"viewRelease": "Ghi chú phát hành",
|
||||
"reasonPluginManaged": "Bản cài này do plugin cache của Claude Code quản lý — hãy chạy /agenthub-update.",
|
||||
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục."
|
||||
}
|
||||
|
||||
+97
-5
@@ -1255,6 +1255,8 @@ export interface UpdateStatusPayload {
|
||||
* shape (e.g. show "Restart after running" only when the command
|
||||
* actually rewrites the working tree). */
|
||||
situation?:
|
||||
| "plugin_managed"
|
||||
| "release_available"
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
@@ -1285,9 +1287,79 @@ export interface UpdateStatusPayload {
|
||||
* restart itself). Absent from `status`/`check` responses. */
|
||||
applied?: boolean;
|
||||
/** Set alongside `applied: false` on an `/apply` response that declined to
|
||||
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
|
||||
* detached HEAD, where there's no safe automatic move). */
|
||||
reason?: "up_to_date" | "not_fast_forwardable";
|
||||
* act - `"up_to_date"`, `"not_fast_forwardable"` (feature branch /
|
||||
* detached HEAD, where there's no safe automatic move), or
|
||||
* `"plugin_managed"` (the install lives in the Claude Code plugin cache,
|
||||
* which only `/reload-plugins` can swap - the server must not touch it). */
|
||||
reason?: "up_to_date" | "not_fast_forwardable" | "plugin_managed";
|
||||
|
||||
// ── Release layer ──
|
||||
/** How this copy was installed. `"plugin"` means it runs out of
|
||||
* `~/.claude/plugins/cache/`, where self-update is refused. */
|
||||
install_kind?: "plugin" | "checkout";
|
||||
/** Marketplace the plugin came from (e.g. `"sg"`), parsed from the cache
|
||||
* path. null for a checkout install. Used to print the exact
|
||||
* `claude plugin update <plugin>@<marketplace>` command. */
|
||||
plugin_marketplace?: string | null;
|
||||
/** Version this install declares in its own package.json, e.g. `"1.5.0"`.
|
||||
* Compared against `latest_release` to decide `update_available`. */
|
||||
local_version?: string | null;
|
||||
/** True for a `--depth` clone, where every commit-distance field is
|
||||
* meaningless and therefore null - the UI must not render an
|
||||
* "N commits behind" badge. The Claude Code plugin cache is one. */
|
||||
shallow_clone?: boolean;
|
||||
/** `"ok"`, or why the release lookup produced nothing: `"no_remote"`,
|
||||
* `"remote_not_http"`, `"no_releases"`, `"unreachable"`, `"bad_payload"`,
|
||||
* or `"http_<status>"`. Anything but `"ok"` means detection fell back to
|
||||
* the commit-count comparison. */
|
||||
release_check?: string;
|
||||
/** Tag of the newest published release, e.g. `"v1.5.0"`. null when
|
||||
* `release_check !== "ok"`. */
|
||||
latest_release?: string | null;
|
||||
/** Human title of that release, when the forge has one. */
|
||||
latest_release_name?: string | null;
|
||||
/** Browser URL of the release page, for a "view release notes" link. */
|
||||
latest_release_url?: string | null;
|
||||
/** ISO timestamp the release was published. */
|
||||
latest_release_published_at?: string | null;
|
||||
/** Release body/changelog, truncated server-side to 4000 chars. */
|
||||
latest_release_notes?: string | null;
|
||||
/** Commit the release tag resolves to locally; null when the tag hasn't
|
||||
* been fetched yet. */
|
||||
release_sha?: string | null;
|
||||
/** Commits in the release that this install lacks. null on a shallow
|
||||
* clone (see `shallow_clone`). */
|
||||
commits_behind_release?: number | null;
|
||||
/** Commits this install carries that the release does not. null on a
|
||||
* shallow clone. Drives the "dev build" label. */
|
||||
commits_ahead_of_release?: number | null;
|
||||
/** True when this install is strictly ahead of the newest release and
|
||||
* missing nothing from it - a build off the branch, not a shipped
|
||||
* version. Never true at the same time as `update_available`. */
|
||||
dev_build?: boolean;
|
||||
/** Plugin name parsed from the cache path (e.g. `"agenthub"`). null for a
|
||||
* checkout install. Combined with `plugin_marketplace` it forms the
|
||||
* `claude plugin update <plugin>@<marketplace>` target. */
|
||||
plugin_name?: string | null;
|
||||
/** Present only on an `/apply` response. `"plugin"` means the detached
|
||||
* plugin chain took the job (minutes, and the old server stays up while it
|
||||
* installs); absent means an in-place git fast-forward + restart. */
|
||||
mode?: "plugin";
|
||||
/** Runtime directory the plugin chain writes its log/verdict into. Present
|
||||
* only on an `/apply` response with `mode: "plugin"`. */
|
||||
runtime_dir?: string;
|
||||
/** Verdict of the most recent plugin-update chain, or null when there has
|
||||
* never been one. `ok: false` is the ONLY way a failed chain becomes
|
||||
* visible - the server that would have reported it is the one that never
|
||||
* came back. Read on every status poll for a plugin install. */
|
||||
last_plugin_update?: {
|
||||
ok: boolean;
|
||||
step: string;
|
||||
error?: string;
|
||||
note?: string;
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
@@ -1316,8 +1388,28 @@ export interface CcConfigChangedPayload {
|
||||
/** Whether the dashboard itself made the write, or an fs watcher observed
|
||||
* an external change (e.g. the user editing a file directly). */
|
||||
source: "dashboard" | "fs";
|
||||
/** Mutation kind; absent for some fs-watcher events that only report scope. */
|
||||
action?: "write" | "delete";
|
||||
/** Mutation kind; absent for some fs-watcher events that only report scope.
|
||||
* Covers the file editor (`write`/`delete`), settings (`patch`), the
|
||||
* `claude plugin …` routes
|
||||
* (`install`/`enable`/`disable`/`update`/`uninstall` for a plugin,
|
||||
* `add`/`remove`/`update` for a marketplace) and the auth profiles
|
||||
* (`save`/`rename`/`activate`/`delete`). Keep in step with the `emitChanged`
|
||||
* call sites in `server/routes/cc-config.js` — the union was previously just
|
||||
* `write | delete`, which every plugin route already contradicted. */
|
||||
action?:
|
||||
| "write"
|
||||
| "delete"
|
||||
| "patch"
|
||||
| "install"
|
||||
| "enable"
|
||||
| "disable"
|
||||
| "update"
|
||||
| "uninstall"
|
||||
| "add"
|
||||
| "remove"
|
||||
| "save"
|
||||
| "rename"
|
||||
| "activate";
|
||||
/** "user" (~/.claude) or "project" (repo's .claude) config root affected.
|
||||
* Lets a listening tab decide whether the change is relevant to its view. */
|
||||
scope?: "user" | "project";
|
||||
|
||||
Reference in New Issue
Block a user