// App-internal instance visibility: apps MAY expose GET /.carlos/instances // on their public host (the contract is INSTANCES.md, next to this file) and // the console renders what they report. The fetch is deliberately meek — a // short timeout, a small size cap, a brief cache — because an app that is // slow, asleep, or silent must cost the app page nothing but this section. package console import ( "context" "encoding/json" "io" "net/http" "strings" "sync" "time" ) const ( // InstancesPath is the well-known path of the instance-report contract. InstancesPath = "/.carlos/instances" // instancesTimeout bounds one report fetch: past it the app is simply // "not reporting" — the page never hangs on a slow tenant. instancesTimeout = 1500 * time.Millisecond // instancesTTL is how long a fetched report (or a failure) is reused // before the host is asked again. instancesTTL = 30 * time.Second // instancesMaxBody caps the response body read; bigger is "not reporting". instancesMaxBody = 64 << 10 // instancesMaxCount caps how many instances one report may list. instancesMaxCount = 100 ) // Instance is one row of an app's self-report. Slug is required; everything // else is optional color. Unknown JSON fields are ignored. type Instance struct { Slug string `json:"slug"` Status string `json:"status"` Region string `json:"region"` Detail string `json:"detail"` } // InstanceReport is what the app page renders: which host was asked and what // it said. Reporting is false when the host was polled but didn't answer the // contract (no endpoint, an error, a timeout, junk) — the page then renders // exactly as it did before this feature existed. type InstanceReport struct { Host string Instances []Instance Reporting bool } // instanceFetcher polls apps for their reports through their public hosts, // caching per host so page reloads don't drum on tenants. type instanceFetcher struct { client *http.Client scheme string timeout time.Duration mu sync.Mutex cache map[string]instanceCacheEntry } type instanceCacheEntry struct { report InstanceReport fetched time.Time } func newInstanceFetcher(client *http.Client, scheme string) *instanceFetcher { if client == nil { client = &http.Client{Timeout: instancesTimeout} } if scheme == "" { scheme = "https" } return &instanceFetcher{ client: client, scheme: scheme, timeout: instancesTimeout, cache: map[string]instanceCacheEntry{}, } } // Report picks the app's reporting host and fetches (or reuses) its report. // The host is the first ready, non-static, non-sidecar route that has NOT // opted into hibernation — polling a hibernatable host could wake it, and // the console must never wake an app just to draw a table. A sidecar route // is excluded for a different reason: its Host is a derived // .sidecar.internal label nothing can dial (design §4.1), so choosing it // would make a HEALTHY app read "not reporting" purely because the picker // landed on an unroutable row from the same app's route set (Assemble groups // by app NAME with no kind filter). nil means no route is pollable, which // renders the same as "not reporting". func (f *instanceFetcher) Report(ctx context.Context, routes []RouteView) *InstanceReport { for _, rt := range routes { if rt.Host == "" || rt.Kind == "static" || rt.Kind == "sidecar" || rt.Status != "ready" || rt.Hibernate { continue } rep := f.get(ctx, rt.Host) return &rep } return nil } func (f *instanceFetcher) get(ctx context.Context, host string) InstanceReport { now := time.Now() f.mu.Lock() if e, ok := f.cache[host]; ok && now.Sub(e.fetched) < instancesTTL { f.mu.Unlock() return e.report } f.mu.Unlock() rep := f.fetch(ctx, host) f.mu.Lock() f.cache[host] = instanceCacheEntry{report: rep, fetched: now} f.mu.Unlock() return rep } // fetch asks one host once. Every failure mode — connect error, non-200, // oversized body, invalid JSON — folds to Reporting=false; the contract is // opt-in and silence is a fine answer. func (f *instanceFetcher) fetch(ctx context.Context, host string) InstanceReport { rep := InstanceReport{Host: host} ctx, cancel := context.WithTimeout(ctx, f.timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.scheme+"://"+host+InstancesPath, nil) if err != nil { return rep } res, err := f.client.Do(req) if err != nil { return rep } defer res.Body.Close() if res.StatusCode != http.StatusOK { return rep } body, err := io.ReadAll(io.LimitReader(res.Body, instancesMaxBody+1)) if err != nil || len(body) > instancesMaxBody { return rep } var raw []Instance if err := json.Unmarshal(body, &raw); err != nil { return rep } var out []Instance for _, in := range raw { if strings.TrimSpace(in.Slug) == "" { continue // slug is the one required field; slugless rows are noise } out = append(out, in) if len(out) == instancesMaxCount { break } } rep.Instances, rep.Reporting = out, true return rep }