0.36.0: per-stack image-update indicator on the Stacks overview
Shows an amber "Update" pill next to a stack's status (and highlights the
inline Update button) when any of the stack's images has a newer digest in
the registry. Reuses the existing background image-update check — a new
update_service.stacks_update_summary() reads the cached digests in a single
container sweep (no extra registry calls), exposed as GET /api/stacks/updates
and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page
and each remote-host section poll it every 60s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a4b1bbcdd1
commit
cd15cdc75e
@@ -280,6 +280,12 @@ def stacks_stats() -> dict:
|
|||||||
return stats_service.stack_stats()
|
return stats_service.stack_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent/stacks/updates", dependencies=[Depends(verify_token)])
|
||||||
|
def stacks_updates() -> dict:
|
||||||
|
"""Per-stack image-update availability from the cached digests."""
|
||||||
|
return update_service.stacks_update_summary()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
||||||
def get_stack(stack_id: str) -> dict:
|
def get_stack(stack_id: str) -> dict:
|
||||||
directory = compose_service.stack_dir(stack_id)
|
directory = compose_service.stack_dir(stack_id)
|
||||||
|
|||||||
@@ -226,6 +226,16 @@ async def agent_stacks_stats(
|
|||||||
return await _proxy(session, agent, "GET", "/agent/stacks/stats")
|
return await _proxy(session, agent, "GET", "/agent/stacks/stats")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/stacks/updates")
|
||||||
|
async def agent_stacks_updates(
|
||||||
|
agent_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
return await _proxy(session, agent, "GET", "/agent/stacks/updates")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{agent_id}/stacks/{stack_id}")
|
@router.get("/{agent_id}/stacks/{stack_id}")
|
||||||
async def agent_stack_detail(
|
async def agent_stack_detail(
|
||||||
agent_id: int,
|
agent_id: int,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from models.setting import (
|
|||||||
)
|
)
|
||||||
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import audit_service, auto_update_service, compose_service, notify_service, stats_service
|
from services import audit_service, auto_update_service, compose_service, notify_service, stats_service, update_service
|
||||||
from services.convert_service import convert_docker_run
|
from services.convert_service import convert_docker_run
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
||||||
@@ -144,6 +144,13 @@ def stacks_stats(_user: User = Depends(get_current_user)) -> dict:
|
|||||||
return stats_service.stack_stats()
|
return stats_service.stack_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates")
|
||||||
|
def stacks_updates(_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Per-stack image-update availability, read from the cached registry
|
||||||
|
digests (no live registry calls — safe for the list to poll)."""
|
||||||
|
return update_service.stacks_update_summary()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{stack_id}")
|
@router.get("/{stack_id}")
|
||||||
def get_stack(
|
def get_stack(
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
|
|||||||
@@ -215,6 +215,38 @@ def stack_images(stack_id: str) -> set[str]:
|
|||||||
return images
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def stacks_update_summary() -> dict[str, dict]:
|
||||||
|
"""Per-stack image-update status for every running compose project, read
|
||||||
|
from the digest cache the background loop maintains — no registry calls, so
|
||||||
|
it's cheap enough for the stacks list to poll. Stacks with no cached image
|
||||||
|
yet are simply absent (treated as "no update" by the UI)."""
|
||||||
|
by_stack: dict[str, set[str]] = {}
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
for c in safe_call(client.containers.list, all=True):
|
||||||
|
project = (c.labels or {}).get("com.docker.compose.project")
|
||||||
|
if not project:
|
||||||
|
continue
|
||||||
|
cfg_image = c.attrs.get("Config", {}).get("Image")
|
||||||
|
if cfg_image:
|
||||||
|
by_stack.setdefault(project, set()).add(cfg_image)
|
||||||
|
except DockerError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
summary: dict[str, dict] = {}
|
||||||
|
for stack_id, images in by_stack.items():
|
||||||
|
stale = [
|
||||||
|
img
|
||||||
|
for img in images
|
||||||
|
if (st := _CACHE.get(img)) is not None and st.update_available
|
||||||
|
]
|
||||||
|
summary[stack_id] = {
|
||||||
|
"update_available": bool(stale),
|
||||||
|
"stale_images": stale,
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
async def stack_updates(stack_id: str, refresh: bool = True) -> dict:
|
async def stack_updates(stack_id: str, refresh: bool = True) -> dict:
|
||||||
"""Update status for one stack's images.
|
"""Update status for one stack's images.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.35.0"
|
APP_VERSION = "0.36.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.35.0",
|
"version": "0.36.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { Agent, StackDetail, StackStats, StackSummary } from "@/types";
|
import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
||||||
|
|
||||||
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
|
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
|
||||||
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
|
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
|
||||||
@@ -35,6 +35,10 @@ export const agentsApi = {
|
|||||||
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
|
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
|
||||||
stackStats: (id: number) =>
|
stackStats: (id: number) =>
|
||||||
api.get<Record<string, StackStats>>(`/api/agents/${id}/stacks/stats`).then((r) => r.data),
|
api.get<Record<string, StackStats>>(`/api/agents/${id}/stacks/stats`).then((r) => r.data),
|
||||||
|
stackUpdates: (id: number) =>
|
||||||
|
api
|
||||||
|
.get<Record<string, StackUpdateInfo>>(`/api/agents/${id}/stacks/updates`)
|
||||||
|
.then((r) => r.data),
|
||||||
createStack: (id: number, body: { name: string; yaml: string; env?: string }) =>
|
createStack: (id: number, body: { name: string; yaml: string; env?: string }) =>
|
||||||
api
|
api
|
||||||
.post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body)
|
.post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { StackDetail, StackStats, StackSummary } from "@/types";
|
import type { StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
||||||
|
|
||||||
export const stacksApi = {
|
export const stacksApi = {
|
||||||
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
|
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
|
||||||
stats: () => api.get<Record<string, StackStats>>("/api/stacks/stats").then((r) => r.data),
|
stats: () => api.get<Record<string, StackStats>>("/api/stacks/stats").then((r) => r.data),
|
||||||
|
updates: () =>
|
||||||
|
api.get<Record<string, StackUpdateInfo>>("/api/stacks/updates").then((r) => r.data),
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
|
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
|
||||||
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
|
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
|
|||||||
enabled: online,
|
enabled: online,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
const updates = useQuery({
|
||||||
|
queryKey: ["agent-stack-updates", agent.id],
|
||||||
|
queryFn: () => agentsApi.stackUpdates(agent.id),
|
||||||
|
enabled: online,
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
const run = async (action: string, label: string, id: string) => {
|
const run = async (action: string, label: string, id: string) => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
@@ -72,6 +78,7 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
|
|||||||
<StacksTable
|
<StacksTable
|
||||||
stacks={stacks.data}
|
stacks={stacks.data}
|
||||||
stats={stats.data}
|
stats={stats.data}
|
||||||
|
updates={updates.data}
|
||||||
hostCpus={sys.data?.cpu_cores ?? 0}
|
hostCpus={sys.data?.cpu_cores ?? 0}
|
||||||
hostMem={sys.data?.mem_total ?? 0}
|
hostMem={sys.data?.mem_total ?? 0}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import type { StackStats, StackSummary } from "@/types";
|
import type { StackStats, StackSummary, StackUpdateInfo } from "@/types";
|
||||||
|
|
||||||
/** Stack list rendered as a table with live CPU/memory usage meters and inline
|
/** Stack list rendered as a table with live CPU/memory usage meters and inline
|
||||||
* start/stop/restart, shared by the Dashboard and the Stacks page. */
|
* start/stop/restart, shared by the Dashboard and the Stacks page. */
|
||||||
export function StacksTable({
|
export function StacksTable({
|
||||||
stacks,
|
stacks,
|
||||||
stats,
|
stats,
|
||||||
|
updates,
|
||||||
hostCpus,
|
hostCpus,
|
||||||
hostMem,
|
hostMem,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -31,6 +32,7 @@ export function StacksTable({
|
|||||||
}: {
|
}: {
|
||||||
stacks: StackSummary[] | undefined;
|
stacks: StackSummary[] | undefined;
|
||||||
stats: Record<string, StackStats> | undefined;
|
stats: Record<string, StackStats> | undefined;
|
||||||
|
updates?: Record<string, StackUpdateInfo>;
|
||||||
hostCpus: number;
|
hostCpus: number;
|
||||||
hostMem: number;
|
hostMem: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -70,6 +72,7 @@ export function StacksTable({
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
stack={s}
|
stack={s}
|
||||||
stats={stats?.[s.id]}
|
stats={stats?.[s.id]}
|
||||||
|
update={updates?.[s.id]}
|
||||||
hostCpus={hostCpus}
|
hostCpus={hostCpus}
|
||||||
hostMem={hostMem}
|
hostMem={hostMem}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -92,6 +95,7 @@ export function StacksTable({
|
|||||||
function StackRow({
|
function StackRow({
|
||||||
stack,
|
stack,
|
||||||
stats,
|
stats,
|
||||||
|
update,
|
||||||
hostCpus,
|
hostCpus,
|
||||||
hostMem,
|
hostMem,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -106,6 +110,7 @@ function StackRow({
|
|||||||
}: {
|
}: {
|
||||||
stack: StackSummary;
|
stack: StackSummary;
|
||||||
stats?: StackStats;
|
stats?: StackStats;
|
||||||
|
update?: StackUpdateInfo;
|
||||||
hostCpus: number;
|
hostCpus: number;
|
||||||
hostMem: number;
|
hostMem: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -120,6 +125,7 @@ function StackRow({
|
|||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const running = stack.running_count > 0;
|
const running = stack.running_count > 0;
|
||||||
|
const updateAvailable = update?.update_available ?? false;
|
||||||
const canDelete = showDelete && !stack.agent_id;
|
const canDelete = showDelete && !stack.agent_id;
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -149,6 +155,18 @@ function StackRow({
|
|||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
{stack.running_count}/{stack.service_count} svc
|
{stack.running_count}/{stack.service_count} svc
|
||||||
</span>
|
</span>
|
||||||
|
{updateAvailable && (
|
||||||
|
<span
|
||||||
|
title={
|
||||||
|
update?.stale_images?.length
|
||||||
|
? `Image update available:\n${update.stale_images.join("\n")}`
|
||||||
|
: "An image update is available for this stack"
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="h-3 w-3" /> Update
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
@@ -201,13 +219,22 @@ function StackRow({
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
)}
|
)}
|
||||||
{onUpdate && (
|
{onUpdate && (
|
||||||
<IconBtn
|
<button
|
||||||
title="Update (pull latest images & recreate)"
|
title={
|
||||||
|
updateAvailable
|
||||||
|
? "Image update available — pull latest images & recreate"
|
||||||
|
: "Update (pull latest images & recreate)"
|
||||||
|
}
|
||||||
onClick={() => onUpdate(stack.id)}
|
onClick={() => onUpdate(stack.id)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
className={
|
||||||
|
updateAvailable
|
||||||
|
? "rounded-lg p-1.5 text-amber-600 hover:bg-amber-100 disabled:opacity-40 dark:text-amber-400 dark:hover:bg-amber-500/20"
|
||||||
|
: "rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ArrowUpCircle className="h-4 w-4" />
|
<ArrowUpCircle className="h-4 w-4" />
|
||||||
</IconBtn>
|
</button>
|
||||||
)}
|
)}
|
||||||
{showEdit && (
|
{showEdit && (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export function Stacks() {
|
|||||||
queryFn: stacksApi.stats,
|
queryFn: stacksApi.stats,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
const updates = useQuery({
|
||||||
|
queryKey: ["stack-updates"],
|
||||||
|
queryFn: stacksApi.updates,
|
||||||
|
refetchInterval: 60000,
|
||||||
|
});
|
||||||
const info = useQuery({
|
const info = useQuery({
|
||||||
queryKey: ["system"],
|
queryKey: ["system"],
|
||||||
queryFn: systemApi.info,
|
queryFn: systemApi.info,
|
||||||
@@ -127,6 +132,7 @@ export function Stacks() {
|
|||||||
<StacksTable
|
<StacksTable
|
||||||
stacks={filtered}
|
stacks={filtered}
|
||||||
stats={stats.data}
|
stats={stats.data}
|
||||||
|
updates={updates.data}
|
||||||
hostCpus={info.data?.cpu_cores ?? 0}
|
hostCpus={info.data?.cpu_cores ?? 0}
|
||||||
hostMem={info.data?.ram.total ?? 0}
|
hostMem={info.data?.ram.total ?? 0}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export interface StackSummary {
|
|||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StackUpdateInfo {
|
||||||
|
update_available: boolean;
|
||||||
|
stale_images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StackStats {
|
export interface StackStats {
|
||||||
cpu_used: number; // cores in use (1.0 = one full core)
|
cpu_used: number; // cores in use (1.0 = one full core)
|
||||||
cpu_limit: number | null; // summed assigned CPU limit, or null
|
cpu_limit: number | null; // summed assigned CPU limit, or null
|
||||||
|
|||||||
Reference in New Issue
Block a user