diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 44dd384..fe0b94c 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -140,9 +140,20 @@ class DeckyShard(SQLModel, table=True): host_uuid: str = Field(foreign_key="swarm_hosts.uuid", index=True) # JSON list of service names running on this decky (snapshot of assignment). services: str = Field(sa_column=Column("services", _BIG_TEXT, nullable=False, default="[]")) - state: str = Field(default="pending", index=True) # pending|running|failed|torn_down + # Full serialised DeckyConfig from the most recent dispatch or heartbeat. + # Lets the dashboard render the same rich card (hostname/distro/archetype/ + # service_config/mutate_interval) that the local-fleet view uses, without + # needing a live round-trip to the worker for every page render. + decky_config: Optional[str] = Field( + default=None, sa_column=Column("decky_config", _BIG_TEXT, nullable=True) + ) + decky_ip: Optional[str] = Field(default=None) + state: str = Field(default="pending", index=True) # pending|running|failed|torn_down|degraded|tearing_down|teardown_failed last_error: Optional[str] = Field(default=None, sa_column=Column("last_error", Text, nullable=True)) compose_hash: Optional[str] = Field(default=None) + # Timestamp of the last heartbeat that echoed this shard; lets the UI + # show "stale" decks whose agent has gone silent. + last_seen: Optional[datetime] = Field(default=None) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -343,6 +354,15 @@ class DeckyShardView(BaseModel): last_error: Optional[str] = None compose_hash: Optional[str] = None updated_at: datetime + # Enriched fields lifted from the stored DeckyConfig snapshot so the + # dashboard can render the same card shape as the local-fleet view. + hostname: Optional[str] = None + distro: Optional[str] = None + archetype: Optional[str] = None + service_config: dict[str, dict[str, Any]] = {} + mutate_interval: Optional[int] = None + last_mutated: float = 0.0 + last_seen: Optional[datetime] = None class SwarmDeployRequest(BaseModel): diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 865e8c2..0bc5183 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -850,6 +850,27 @@ class SQLModelRepository(BaseRepository): d["services"] = json.loads(raw) except (json.JSONDecodeError, TypeError): d["services"] = [] + # Flatten the stored DeckyConfig snapshot into the row so + # routers can hand it to DeckyShardView without re-parsing. + # Rows predating the migration have decky_config=NULL and + # fall through with the default (None/{}) view values. + cfg_raw = d.get("decky_config") + if isinstance(cfg_raw, str): + try: + cfg = json.loads(cfg_raw) + except (json.JSONDecodeError, TypeError): + cfg = {} + if isinstance(cfg, dict): + for k in ("hostname", "distro", "archetype", + "service_config", "mutate_interval", + "last_mutated"): + if k in cfg and d.get(k) is None: + d[k] = cfg[k] + # Keep decky_ip authoritative from the column (newer + # heartbeats overwrite it) but fall back to the + # snapshot if the column is still NULL. + if not d.get("decky_ip") and cfg.get("ip"): + d["decky_ip"] = cfg["ip"] out.append(d) return out diff --git a/decnet/web/router/swarm/api_deploy_swarm.py b/decnet/web/router/swarm/api_deploy_swarm.py index 2b19ebc..ba77c01 100644 --- a/decnet/web/router/swarm/api_deploy_swarm.py +++ b/decnet/web/router/swarm/api_deploy_swarm.py @@ -89,6 +89,8 @@ async def dispatch_decnet_config( "decky_name": d.name, "host_uuid": host_uuid, "services": json.dumps(d.services), + "decky_config": d.model_dump_json(), + "decky_ip": d.ip, "state": "running" if not dry_run else "pending", "last_error": None, "updated_at": datetime.now(timezone.utc), @@ -118,6 +120,8 @@ async def dispatch_decnet_config( "decky_name": d.name, "host_uuid": host_uuid, "services": json.dumps(d.services), + "decky_config": d.model_dump_json(), + "decky_ip": d.ip, "state": "running" if is_up else "failed", "last_error": None if is_up else str(exc)[:512], "updated_at": datetime.now(timezone.utc), diff --git a/decnet/web/router/swarm/api_list_deckies.py b/decnet/web/router/swarm/api_list_deckies.py index 6017a04..43a5d98 100644 --- a/decnet/web/router/swarm/api_list_deckies.py +++ b/decnet/web/router/swarm/api_list_deckies.py @@ -34,6 +34,7 @@ async def api_list_deckies( host = hosts.get(s["host_uuid"], {}) out.append(DeckyShardView( decky_name=s["decky_name"], + decky_ip=s.get("decky_ip"), host_uuid=s["host_uuid"], host_name=host.get("name") or "", host_address=host.get("address") or "", @@ -43,5 +44,12 @@ async def api_list_deckies( last_error=s.get("last_error"), compose_hash=s.get("compose_hash"), updated_at=s["updated_at"], + hostname=s.get("hostname"), + distro=s.get("distro"), + archetype=s.get("archetype"), + service_config=s.get("service_config") or {}, + mutate_interval=s.get("mutate_interval"), + last_mutated=s.get("last_mutated") or 0.0, + last_seen=s.get("last_seen"), )) return out diff --git a/decnet/web/router/swarm_mgmt/api_list_deckies.py b/decnet/web/router/swarm_mgmt/api_list_deckies.py index 2fe4b90..0f8bb84 100644 --- a/decnet/web/router/swarm_mgmt/api_list_deckies.py +++ b/decnet/web/router/swarm_mgmt/api_list_deckies.py @@ -22,9 +22,8 @@ async def list_deckies( shards = await repo.list_decky_shards(host_uuid) hosts = {h["uuid"]: h for h in await repo.list_swarm_hosts()} - # IPs live on the stored DecnetConfig, not on the shard row. Resolve by - # decky_name — if the master rebooted without a config, the column falls - # back to "—" rather than blocking the list. + # Pre-heartbeat fallback — older rows without decky_config can still + # surface their IP from the master's deploy state snapshot. deploy_state = await repo.get_state("deployment") or {} cfg_deckies = (deploy_state.get("config") or {}).get("deckies") or [] ip_by_name: dict[str, str] = { @@ -38,7 +37,7 @@ async def list_deckies( host = hosts.get(s["host_uuid"], {}) out.append(DeckyShardView( decky_name=s["decky_name"], - decky_ip=ip_by_name.get(s["decky_name"]), + decky_ip=s.get("decky_ip") or ip_by_name.get(s["decky_name"]), host_uuid=s["host_uuid"], host_name=host.get("name") or "", host_address=host.get("address") or "", @@ -48,5 +47,12 @@ async def list_deckies( last_error=s.get("last_error"), compose_hash=s.get("compose_hash"), updated_at=s["updated_at"], + hostname=s.get("hostname"), + distro=s.get("distro"), + archetype=s.get("archetype"), + service_config=s.get("service_config") or {}, + mutate_interval=s.get("mutate_interval"), + last_mutated=s.get("last_mutated") or 0.0, + last_seen=s.get("last_seen"), )) return out