0.31.1: make dashboard metrics honest
- Container card now compares compose-only counts across hosts: agents report compose_running in /agent/system (pre-0.31.1 agents fall back to the all-containers number); card retitled, ResourceBar stat labelled 'Containers (all)'. - Uptime is sampled every 5 min (background loop + opportunistic on read) and charted as daily averages instead of a once-a-day snapshot; no sample is written when no compose containers exist (was: fake 100%). Legacy daily entries in uptime.jsonl still count; file pruned at startup. - Funnel stage 'monitored' is now per-stack and real: stacks with an enabled local auto-update policy (was: global webhook-exists toggle). Frontend label renamed to 'Auto-managed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1609b8bcc3
commit
11effdc2ca
@@ -172,14 +172,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
(`bg`/`card`/`accent`) are remapped onto the tokens so all pages reskin
|
(`bg`/`card`/`accent`) are remapped onto the tokens so all pages reskin
|
||||||
consistently. Typeface: Schibsted Grotesk (bundled, offline-friendly).
|
consistently. Typeface: Schibsted Grotesk (bundled, offline-friendly).
|
||||||
- **Stack Health funnel** — the dashboard centrepiece. Five stages
|
- **Stack Health funnel** — the dashboard centrepiece. Five stages
|
||||||
(`discovered → running → healthy → updated → monitored`) from
|
(`discovered → running → healthy → updated → auto-managed`) from
|
||||||
`GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust).
|
`GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust;
|
||||||
SVG waterfall with alternating gradient / diagonal-hatch bars, value chips
|
the last stage — API key `monitored` — counts stacks with an enabled
|
||||||
and hover conversion/drop-off tooltips.
|
auto-update policy). SVG waterfall with alternating gradient /
|
||||||
- **Summary widgets** from `GET /api/dashboard/summary`: containers-running
|
diagonal-hatch bars, value chips and hover conversion/drop-off tooltips.
|
||||||
card with per-host breakdown and an "Insights" chip (healthy-rate %), a
|
- **Summary widgets** from `GET /api/dashboard/summary`: compose-containers
|
||||||
30-day uptime line chart (daily samples appended to `DATA_DIR/uptime.jsonl`),
|
card with per-host breakdown (agents report a compose-only count) and an
|
||||||
and an ops contribution grid from audit-log activity with the peak weekday.
|
"Insights" chip (healthy-rate %), a 30-day uptime line chart (sampled every
|
||||||
|
5 min into `DATA_DIR/uptime.jsonl`, charted as daily averages), and an ops
|
||||||
|
contribution grid from audit-log activity with the peak weekday.
|
||||||
A 30/7-day range selector slices both series client-side.
|
A 30/7-day range selector slices both series client-side.
|
||||||
- **Explore prompt bar** under the funnel: typed queries or `/running`,
|
- **Explore prompt bar** under the funnel: typed queries or `/running`,
|
||||||
`/stopped`, `/attention` tags deep-link to the Stacks page, which now
|
`/stopped`, `/attention` tags deep-link to the Stacks page, which now
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.31.0"
|
AGENT_VERSION = "0.31.1"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -210,7 +210,7 @@ def _disk_info() -> tuple[int, int]:
|
|||||||
def _system_info() -> dict:
|
def _system_info() -> dict:
|
||||||
docker_version = ""
|
docker_version = ""
|
||||||
host_os = ""
|
host_os = ""
|
||||||
running = total = 0
|
running = total = compose_running = 0
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
client = get_client()
|
||||||
docker_version = safe_call(client.version).get("Version", "")
|
docker_version = safe_call(client.version).get("Version", "")
|
||||||
@@ -218,6 +218,12 @@ def _system_info() -> dict:
|
|||||||
host_os = info.get("OperatingSystem", "")
|
host_os = info.get("OperatingSystem", "")
|
||||||
running = info.get("ContainersRunning", 0)
|
running = info.get("ContainersRunning", 0)
|
||||||
total = info.get("Containers", 0)
|
total = info.get("Containers", 0)
|
||||||
|
# Running compose-managed containers — the dashboard's container card
|
||||||
|
# compares this across hosts; counting everything would include this
|
||||||
|
# agent itself and skew the bars.
|
||||||
|
compose_running = len(
|
||||||
|
safe_call(client.api.containers, filters={"label": compose_service.COMPOSE_LABEL})
|
||||||
|
)
|
||||||
except DockerError as exc:
|
except DockerError as exc:
|
||||||
docker_version = f"unavailable ({exc.error})"
|
docker_version = f"unavailable ({exc.error})"
|
||||||
mem_total, mem_used = _mem_info()
|
mem_total, mem_used = _mem_info()
|
||||||
@@ -233,6 +239,7 @@ def _system_info() -> dict:
|
|||||||
"disk_used": disk_used,
|
"disk_used": disk_used,
|
||||||
"containers_running": running,
|
"containers_running": running,
|
||||||
"containers_total": total,
|
"containers_total": total,
|
||||||
|
"compose_running": compose_running,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -35,7 +35,7 @@ from routers import (
|
|||||||
volumes,
|
volumes,
|
||||||
ws,
|
ws,
|
||||||
)
|
)
|
||||||
from services import schedule_service, template_service, update_service
|
from services import dashboard_service, schedule_service, template_service, update_service
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("stackpilot")
|
logger = logging.getLogger("stackpilot")
|
||||||
@@ -58,13 +58,15 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning("Legacy template migration failed: %s", exc)
|
logger.warning("Legacy template migration failed: %s", exc)
|
||||||
update_task = asyncio.create_task(update_service.background_loop())
|
update_task = asyncio.create_task(update_service.background_loop())
|
||||||
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
|
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
|
||||||
|
uptime_task = asyncio.create_task(dashboard_service.uptime_sampler_loop())
|
||||||
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
||||||
yield
|
yield
|
||||||
update_task.cancel()
|
update_task.cancel()
|
||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
uptime_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.31.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.31.1", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
|
|||||||
from config import settings
|
from config import settings
|
||||||
from docker_client import DockerError, get_client, safe_call
|
from docker_client import DockerError, get_client, safe_call
|
||||||
from models.audit import AuditLog
|
from models.audit import AuditLog
|
||||||
from models.setting import Webhook
|
from models.auto_update import AutoUpdate
|
||||||
from services import compose_service, update_service
|
from services import compose_service, update_service
|
||||||
|
|
||||||
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
|
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
|
||||||
@@ -28,6 +28,7 @@ FUNNEL_TTL = 30.0
|
|||||||
|
|
||||||
UPTIME_FILE = os.path.join(settings.DATA_DIR, "uptime.jsonl")
|
UPTIME_FILE = os.path.join(settings.DATA_DIR, "uptime.jsonl")
|
||||||
UPTIME_DAYS = 30
|
UPTIME_DAYS = 30
|
||||||
|
UPTIME_SAMPLE_INTERVAL = 300.0 # seconds between uptime samples
|
||||||
|
|
||||||
_funnel_cache: dict = {"data": None, "ts": 0.0}
|
_funnel_cache: dict = {"data": None, "ts": 0.0}
|
||||||
|
|
||||||
@@ -77,13 +78,15 @@ def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _has_notify_target(session: Session) -> bool:
|
def _auto_managed_ids(session: Session) -> set[str]:
|
||||||
if settings.NOTIFY_WEBHOOKS:
|
"""Local stacks with an enabled auto-update policy."""
|
||||||
return True
|
rows = session.exec(
|
||||||
for wh in session.exec(select(Webhook)).all():
|
select(AutoUpdate.stack_id).where(
|
||||||
if wh.enabled:
|
AutoUpdate.enabled == True, # noqa: E712 — SQL expression, not identity
|
||||||
return True
|
AutoUpdate.agent_id == None, # noqa: E711
|
||||||
return False
|
)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
||||||
@@ -98,7 +101,7 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
|||||||
raw = []
|
raw = []
|
||||||
by_project = _group_by_project(raw)
|
by_project = _group_by_project(raw)
|
||||||
update_cache = update_service.get_cache()
|
update_cache = update_service.get_cache()
|
||||||
notify_configured = _has_notify_target(session)
|
auto_managed = _auto_managed_ids(session)
|
||||||
|
|
||||||
running = healthy = updated = monitored = 0
|
running = healthy = updated = monitored = 0
|
||||||
for stack_id in discovered_ids:
|
for stack_id in discovered_ids:
|
||||||
@@ -113,7 +116,8 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
|||||||
if not _is_updated(containers, update_cache):
|
if not _is_updated(containers, update_cache):
|
||||||
continue
|
continue
|
||||||
updated += 1
|
updated += 1
|
||||||
if notify_configured:
|
# Final stage: the stack also keeps itself fresh (auto-update enabled).
|
||||||
|
if stack_id in auto_managed:
|
||||||
monitored += 1
|
monitored += 1
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -130,7 +134,12 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Uptime series (one sample per day, JSONL on disk)
|
# Uptime series (sampled every few minutes, aggregated to a daily mean)
|
||||||
|
#
|
||||||
|
# Each sample is one JSONL line {"ts": iso, "value": pct}; a background loop
|
||||||
|
# samples every UPTIME_SAMPLE_INTERVAL and summary reads top up opportunistically,
|
||||||
|
# so the daily value approximates real uptime instead of a once-a-day snapshot.
|
||||||
|
# Legacy pre-0.31.1 lines {"date": d, "value": pct} still count as one sample.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@@ -156,40 +165,98 @@ def _append_uptime(entry: dict) -> None:
|
|||||||
fh.write(json.dumps(entry) + "\n")
|
fh.write(json.dumps(entry) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_date(e: dict) -> Optional[str]:
|
||||||
|
if "date" in e:
|
||||||
|
return e["date"]
|
||||||
|
ts = e.get("ts")
|
||||||
|
return ts[:10] if isinstance(ts, str) and len(ts) >= 10 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_sample_ts(entries: list[dict]) -> float:
|
||||||
|
latest = 0.0
|
||||||
|
for e in entries:
|
||||||
|
ts = e.get("ts")
|
||||||
|
if not isinstance(ts, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
latest = max(latest, datetime.fromisoformat(ts).timestamp())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
def _sample_uptime(raw: list[dict]) -> Optional[dict]:
|
def _sample_uptime(raw: list[dict]) -> Optional[dict]:
|
||||||
"""Append today's sample if not yet recorded. Uptime% = share of compose
|
"""Record a sample if the last one is older than the sample interval.
|
||||||
containers currently running."""
|
|
||||||
today = datetime.now(timezone.utc).date().isoformat()
|
Uptime% = share of compose containers currently running. With no compose
|
||||||
|
containers at all there is nothing to be up — skip rather than fake 100%.
|
||||||
|
"""
|
||||||
entries = _read_uptime()
|
entries = _read_uptime()
|
||||||
if any(e.get("date") == today for e in entries):
|
now = datetime.now(timezone.utc)
|
||||||
|
if now.timestamp() - _latest_sample_ts(entries) < UPTIME_SAMPLE_INTERVAL:
|
||||||
return None
|
return None
|
||||||
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||||
total = len(labelled)
|
if not labelled:
|
||||||
|
return None
|
||||||
running = sum(1 for c in labelled if c.get("State") == "running")
|
running = sum(1 for c in labelled if c.get("State") == "running")
|
||||||
value = round(running / total * 100, 1) if total else 100.0
|
value = round(running / len(labelled) * 100, 1)
|
||||||
entry = {"date": today, "value": value}
|
entry = {"ts": now.isoformat(timespec="seconds"), "value": value}
|
||||||
_append_uptime(entry)
|
_append_uptime(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def prune_uptime_file() -> None:
|
||||||
|
"""Drop samples older than twice the chart window (called at startup)."""
|
||||||
|
entries = _read_uptime()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
cutoff = (datetime.now(timezone.utc).date() - timedelta(days=UPTIME_DAYS * 2)).isoformat()
|
||||||
|
kept = [e for e in entries if (_entry_date(e) or cutoff) >= cutoff]
|
||||||
|
if len(kept) == len(entries):
|
||||||
|
return
|
||||||
|
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
||||||
|
tmp = UPTIME_FILE + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
for e in kept:
|
||||||
|
fh.write(json.dumps(e) + "\n")
|
||||||
|
os.replace(tmp, UPTIME_FILE)
|
||||||
|
|
||||||
|
|
||||||
def uptime_series(raw: list[dict]) -> list[dict]:
|
def uptime_series(raw: list[dict]) -> list[dict]:
|
||||||
_sample_uptime(raw)
|
_sample_uptime(raw)
|
||||||
entries = _read_uptime()
|
by_date: dict[str, list[float]] = {}
|
||||||
by_date = {e["date"]: e for e in entries if "date" in e}
|
for e in _read_uptime():
|
||||||
|
day = _entry_date(e)
|
||||||
|
value = e.get("value")
|
||||||
|
if day and isinstance(value, (int, float)):
|
||||||
|
by_date.setdefault(day, []).append(float(value))
|
||||||
series = []
|
series = []
|
||||||
today = datetime.now(timezone.utc).date()
|
today = datetime.now(timezone.utc).date()
|
||||||
last_value: Optional[float] = None
|
last_value: Optional[float] = None
|
||||||
for i in range(UPTIME_DAYS - 1, -1, -1):
|
for i in range(UPTIME_DAYS - 1, -1, -1):
|
||||||
day = (today - timedelta(days=i)).isoformat()
|
day = (today - timedelta(days=i)).isoformat()
|
||||||
e = by_date.get(day)
|
samples = by_date.get(day)
|
||||||
if e is not None:
|
if samples:
|
||||||
last_value = e.get("value")
|
last_value = round(sum(samples) / len(samples), 1)
|
||||||
# Days before monitoring started (or gaps) reuse the last known value
|
# Days before monitoring started (or gaps) reuse the last known value
|
||||||
# so the chart doesn't show artificial dips.
|
# so the chart doesn't show artificial dips.
|
||||||
series.append({"date": day, "value": last_value})
|
series.append({"date": day, "value": last_value})
|
||||||
return series
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
async def uptime_sampler_loop() -> None:
|
||||||
|
"""Background task: keep the uptime series fed even when nobody is
|
||||||
|
looking at the dashboard."""
|
||||||
|
prune_uptime_file()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await _containers_with_timeout()
|
||||||
|
await asyncio.to_thread(_sample_uptime, raw)
|
||||||
|
except (DockerError, asyncio.TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(UPTIME_SAMPLE_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Ops (audit-log) activity
|
# Ops (audit-log) activity
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.31.0",
|
"version": "0.31.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface AgentSystem {
|
|||||||
disk_used: number;
|
disk_used: number;
|
||||||
containers_running: number;
|
containers_running: number;
|
||||||
containers_total: number;
|
containers_total: number;
|
||||||
|
compose_running?: number; // agents < 0.31.1 don't report it
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ const FUNNEL_STAGES = [
|
|||||||
{ key: "running", label: "Running" },
|
{ key: "running", label: "Running" },
|
||||||
{ key: "healthy", label: "Healthy" },
|
{ key: "healthy", label: "Healthy" },
|
||||||
{ key: "updated", label: "Up to date" },
|
{ key: "updated", label: "Up to date" },
|
||||||
{ key: "monitored", label: "Monitored" },
|
{ key: "monitored", label: "Auto-managed" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function StackHealthCard({
|
function StackHealthCard({
|
||||||
@@ -302,14 +302,19 @@ function ContainerCountCard({
|
|||||||
|
|
||||||
const hosts: { name: string; count: number }[] = [
|
const hosts: { name: string; count: number }[] = [
|
||||||
{ name: "local", count: localRunning ?? 0 },
|
{ name: "local", count: localRunning ?? 0 },
|
||||||
...online.map((a, i) => ({ name: a.name, count: remote[i].data?.containers_running ?? 0 })),
|
// compose_running keeps the bars comparable with the local compose-only
|
||||||
|
// count; pre-0.31.1 agents only report the all-containers number.
|
||||||
|
...online.map((a, i) => ({
|
||||||
|
name: a.name,
|
||||||
|
count: remote[i].data?.compose_running ?? remote[i].data?.containers_running ?? 0,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
const total = hosts.reduce((s, h) => s + h.count, 0);
|
const total = hosts.reduce((s, h) => s + h.count, 0);
|
||||||
const max = Math.max(...hosts.map((h) => h.count), 1);
|
const max = Math.max(...hosts.map((h) => h.count), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
|
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
|
||||||
<h2 className="sp-label">Containers running</h2>
|
<h2 className="sp-label">Compose containers running</h2>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="sp-skeleton h-14 w-28" />
|
<div className="sp-skeleton h-14 w-28" />
|
||||||
@@ -379,7 +384,9 @@ function UptimeCard({
|
|||||||
<UptimeChart data={values} />
|
<UptimeChart data={values} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-sp-text-3">Daily share of compose containers running, this host.</p>
|
<p className="mt-2 text-xs text-sp-text-3">
|
||||||
|
Share of compose containers running on this host, sampled every 5 min (daily average).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -552,7 +559,7 @@ function ResourceBar({
|
|||||||
/>
|
/>
|
||||||
<Stat
|
<Stat
|
||||||
icon={<Container className="h-5 w-5" />}
|
icon={<Container className="h-5 w-5" />}
|
||||||
label="Containers"
|
label="Containers (all)"
|
||||||
value={`${containersRunning} / ${containersTotal}`}
|
value={`${containersRunning} / ${containersTotal}`}
|
||||||
/>
|
/>
|
||||||
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
|
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
|
||||||
|
|||||||
Reference in New Issue
Block a user