Skip to content

Commit 4b7ea0c

Browse files
Enhance container port metadata with protocol, label, and browsable flag (#24)
* feat: sub-container port state — protocol, label and browsable flag (spec C.7/C.3) Adapt the SDK to GladysAssistant/Gladys#2786, which adds a `browsable` field (default true) to the manifest sub-container ports and returns the full port shape from `GET /container`: a port that serves no web UI (a WebSocket endpoint waiting for devices, the OCPP case) is shown as a plain badge instead of an "Open" link. - ContainerPort typings resynced with the core state: `protocol`, `label` and `browsable` added, `host_port` corrected to `number | null` (no host port is allocated before the first start); new exported ContainerPortProtocol union - getContainers JSDoc + README document the full port entry and what browsable: false means for the integration - tests: the getContainers fixture carries the real port shape, plus a non-browsable port with an unassigned host port; compile-time checks on the new fields Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxpcyV2TFQvTJkYdTa9YET * test(types): assert host_port nullability with a direct assignment The optional chaining on `containers[0]?.ports[0]?.host_port` contributes `undefined` on its own, so the widened `number | null | undefined` annotation type-checked even when `host_port` was `number` — the check did not actually prove the field is nullable. Assign `null` straight to `ContainerPort['host_port']` instead, which fails to compile without the null in the type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxpcyV2TFQvTJkYdTa9YET --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c29defe commit 4b7ea0c

5 files changed

Lines changed: 79 additions & 6 deletions

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ All methods return Promises; host API errors are thrown as `GladysApiError { sta
124124
| `getConfig()` / `setConfig(partialConfig)` | Configuration values; `getConfig` also refreshes `gladys.config` |
125125
| `getStatus()` | Gladys version + integration service status |
126126
| `setConnectionStatus(connected, message?)` | Application-level connection status shown in the Configuration screen (`message` is an optional multi-language object, e.g. `{ en: 'Token expired' }`). Distinct from the container state machine: a cloud integration can be RUNNING and still disconnected from its third-party service |
127-
| `getContainers()` | Sub-containers declared in the manifest: Docker status, desired state, assigned host ports, granted/available hardware classes |
127+
| `getContainers()` | Sub-containers declared in the manifest: Docker status, desired state, published ports (`{ container_port, protocol, host_port, label, browsable }`, `host_port: null` while none is assigned yet), granted/available hardware classes |
128128
| `startContainer(name, { env }?)` | Creates (if needed) and starts a declared sub-container — typically after generating its config files in `/data`; `env` carries runtime-computed values (secrets never go through the public manifest) |
129129
| `stopContainer(name)` | Stops a sub-container; the supervisor will not restart it |
130130
| `restartContainer(name)` | Restarts a sub-container, e.g. after rewriting its config through `/data` |
@@ -607,6 +607,19 @@ const detector = coral.granted && coral.available ? 'edgetpu' : 'cpu'; // adapt
607607
When the user changes the hardware grants, the affected sub-containers are recreated and `onHardwareUpdated` fires:
608608
regenerate the configs and (re)start what is needed.
609609

610+
Each entry of `container.ports` mirrors the manifest declaration plus the host port Gladys allocated:
611+
612+
```js
613+
// [{ container_port: 5000, protocol: 'tcp', host_port: 42115, label: { en: 'Frigate UI' }, browsable: true }]
614+
const [{ host_port: frigatePort }] = frigate.ports;
615+
```
616+
617+
The host port is **chosen by Gladys** (a free port, persisted across recreations — never declared in the manifest),
618+
so read it here rather than assuming one; it is `null` as long as none has been assigned (the container has never
619+
started). `browsable` mirrors the manifest field: `true` (default) for a port serving a web UI — the supervision
620+
screen shows an "Open <label>" link — and `false` for a port a browser cannot open, e.g. a WebSocket endpoint
621+
waiting for devices (the OCPP case), which is shown as a plain `<label> : <host_port>` badge instead.
622+
610623
### Mediated network discovery
611624

612625
Integration containers run on a bridge network: LAN **broadcast, mDNS and SSDP traffic never reaches them**, and a

index.d.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,25 @@ export interface PublishDiscoveredDevicesResponse extends SuccessResponse {
105105
*/
106106
export type MultiLanguageMessage = { en: string } & Record<string, string>;
107107

108+
/** Protocol of a published sub-container port. */
109+
export type ContainerPortProtocol = 'tcp' | 'udp';
110+
108111
/** A published port of a sub-container, with the host port assigned by Gladys. */
109112
export interface ContainerPort {
110113
container_port: number;
111-
host_port: number;
114+
/** Protocol declared in the manifest, `tcp` when omitted. */
115+
protocol: ContainerPortProtocol;
116+
/** Host port assigned by Gladys, `null` while none has been allocated yet. */
117+
host_port: number | null;
118+
/** Multi-language label of the port, as declared in the manifest. */
119+
label: MultiLanguageMessage;
120+
/**
121+
* Whether the port serves a web UI reachable from a browser. `false` (manifest
122+
* `browsable: false`, e.g. a WebSocket endpoint waiting for devices) — the
123+
* supervision screen shows the assigned host port as a badge, without the
124+
* "Open" link.
125+
*/
126+
browsable: boolean;
112127
}
113128

114129
/** State of one requested hardware class of a sub-container (contract C.3). */

lib/gladys-integration.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,10 @@ class GladysIntegration extends EventEmitter {
865865
* @description Fetch the sub-containers declared in the manifest: their
866866
* Docker status, desired state, assigned host ports and, per requested
867867
* hardware class, the granted/available flags (contract C.3) — how the
868-
* integration knows what to put in its generated configs.
868+
* integration knows what to put in its generated configs. Each port carries
869+
* `{ container_port, protocol, host_port, label, browsable }`; `host_port` is
870+
* `null` while Gladys has not allocated one yet, and `browsable: false` marks
871+
* a port that serves no web UI (a WebSocket endpoint for devices, say).
869872
* @returns {Promise<Array>} The containers; empty if none is declared.
870873
* @example
871874
* const containers = await gladys.getContainers();

test/containers.test.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,48 @@ describe('connection status & sub-container methods', () => {
4343
status: 'stopped',
4444
desired: 'stopped',
4545
started_at: null,
46-
ports: [{ container_port: 5000, host_port: 42115 }],
46+
ports: [
47+
{
48+
container_port: 5000,
49+
protocol: 'tcp',
50+
host_port: 42115,
51+
label: { en: 'Frigate UI', fr: 'Interface Frigate' },
52+
browsable: true,
53+
},
54+
],
4755
devices: [{ class: 'coral-usb', granted: true, available: true }],
4856
},
4957
];
5058
const containers = await gladys.getContainers();
5159
assert.deepEqual(containers, server.containers);
5260
});
61+
62+
it('should keep the browsable flag and the not-yet-assigned host port of a port', async () => {
63+
// OCPP-like case: a WebSocket endpoint for devices, no web UI to open
64+
server.containers = [
65+
{
66+
name: 'ocpp',
67+
status: 'stopped',
68+
desired: 'stopped',
69+
started_at: null,
70+
ports: [
71+
{
72+
container_port: 9000,
73+
protocol: 'tcp',
74+
host_port: null,
75+
label: { en: 'OCPP WebSocket' },
76+
browsable: false,
77+
},
78+
],
79+
devices: [],
80+
},
81+
];
82+
const [container] = await gladys.getContainers();
83+
assert.equal(container.ports[0].browsable, false);
84+
assert.equal(container.ports[0].host_port, null);
85+
assert.equal(container.ports[0].protocol, 'tcp');
86+
assert.deepEqual(container.ports[0].label, { en: 'OCPP WebSocket' });
87+
});
5388
});
5489

5590
describe('gladys.startContainer(name, options?)', () => {

test/types/api.test-d.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
import {
66
ActionFields,
7+
ContainerPort,
8+
ContainerPortProtocol,
79
createLogger,
810
Device,
911
DEVICE_FEATURE_CATEGORIES,
@@ -144,13 +146,18 @@ const main = async (): Promise<void> => {
144146

145147
await gladys.setConnectionStatus(false, { en: 'Token expired, please reconnect.', fr: 'Token expiré.' });
146148
const containers: IntegrationContainer[] = await gladys.getContainers();
147-
const hostPort: number | undefined = containers[0]?.ports[0]?.host_port;
149+
const hostPort: number | null | undefined = containers[0]?.ports[0]?.host_port;
150+
// direct assignment: the optional chaining above would type-check even without the null
151+
const unassignedHostPort: ContainerPort['host_port'] = null;
152+
const portProtocol: ContainerPortProtocol | undefined = containers[0]?.ports[0]?.protocol;
153+
const browsable: boolean | undefined = containers[0]?.ports[0]?.browsable;
154+
const portLabel: string | undefined = containers[0]?.ports[0]?.label.en;
148155
await gladys.startContainer('mqtt', { env: { MQTT_PASSWORD: 's3cr3t' } });
149156
await gladys.startContainer('mqtt');
150157
await gladys.stopContainer('mqtt');
151158
await gladys.restartContainer('frigate');
152159
const oauthType: string = WEBSOCKET_MESSAGE_TYPES.EXTERNAL_INTEGRATION.OAUTH_GET_AUTHORIZE_URL;
153-
void [hostPort, oauthType];
160+
void [hostPort, unassignedHostPort, portProtocol, browsable, portLabel, oauthType];
154161

155162
gladys.onWebhook('events', async (request: WebhookRequest) => {
156163
const line: string = `${request.method} ${request.body ?? ''} ${request.contentType ?? ''}`;

0 commit comments

Comments
 (0)