From f13cffef6f845bec5f10e0c03290f1aec0bf35bc Mon Sep 17 00:00:00 2001 From: madhavilosetty-intel Date: Tue, 25 Aug 2026 15:21:38 -0700 Subject: [PATCH] feat(api): cache device power state on the devices table Add powerstate, ospowersavingstate and powerstateupdatedat columns to the devices table and surface them on the Device model, so GET /api/v1/devices can return last known power state for a whole fleet in a single query instead of one live call per device. Add a targeted updatePowerState to IDeviceTable for both the postgres and mongo providers. It is kept separate from update() so the two writers of this row keep disjoint SET lists and cannot clobber each other. Both providers let DB errors propagate so the caller can tell a missing device from an unreachable database and back off accordingly. Power state is server owned, so the three fields are dropped from the device update request body. Existing deployments need an ALTER TABLE adding the three columns before this version starts, since getById is on the CIRA authentication path. Resolves: #2614 --- data/initMPS.sql | 3 + src/data/mongo/collections/device.test.ts | 42 +++++++++ src/data/mongo/collections/device.ts | 15 ++++ src/data/postgres/tables/device.test.ts | 105 ++++++++++++++++++++-- src/data/postgres/tables/device.ts | 62 +++++++++++-- src/interfaces/IDeviceTable.ts | 7 ++ src/models/models.ts | 3 + src/routes/devices/update.test.ts | 34 +++++++ src/routes/devices/update.ts | 4 +- 9 files changed, 260 insertions(+), 15 deletions(-) diff --git a/data/initMPS.sql b/data/initMPS.sql index 10549700b..8a1420d54 100644 --- a/data/initMPS.sql +++ b/data/initMPS.sql @@ -20,6 +20,9 @@ CREATE TABLE IF NOT EXISTS devices( lastseen timestamp with time zone, lastdisconnected timestamp with time zone, deviceinfo JSON, + powerstate integer, + ospowersavingstate integer, + powerstateupdatedat timestamp with time zone, CONSTRAINT device_guid UNIQUE(guid), PRIMARY KEY (guid,tenantid) ); diff --git a/src/data/mongo/collections/device.test.ts b/src/data/mongo/collections/device.test.ts index 04f12243e..057f95c61 100644 --- a/src/data/mongo/collections/device.test.ts +++ b/src/data/mongo/collections/device.test.ts @@ -22,6 +22,7 @@ describe('MongoDeviceTable', () => { insertOne: vi.fn(), findOneAndUpdate: vi.fn(), distinct: vi.fn(), + updateOne: vi.fn(), updateMany: vi.fn() } as any @@ -156,6 +157,47 @@ describe('MongoDeviceTable', () => { expect(result).toEqual(mockData) }) + it('should update the power state for a device', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + collection.updateOne.mockResolvedValue({ matchedCount: 1 } as any) + + const result = await mongoDeviceTable.updatePowerState('someGuid', 4, 2, updatedAt, 'someTenantId') + + expect(result).toBe(true) + expect(collection.updateOne).toHaveBeenCalledWith( + { guid: 'someGuid', tenantId: 'someTenantId' }, + { $set: { powerState: 4, osPowerSavingState: 2, powerStateUpdatedAt: updatedAt } } + ) + }) + + it('should default to an empty tenantId when the power state update omits it', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + collection.updateOne.mockResolvedValue({ matchedCount: 1 } as any) + + await mongoDeviceTable.updatePowerState('someGuid', 4, 2, updatedAt) + + expect(collection.updateOne).toHaveBeenCalledWith( + { guid: 'someGuid', tenantId: '' }, + { $set: { powerState: 4, osPowerSavingState: 2, powerStateUpdatedAt: updatedAt } } + ) + }) + + it('should return false when no device matches the power state update', async () => { + collection.updateOne.mockResolvedValue({ matchedCount: 0 } as any) + + const result = await mongoDeviceTable.updatePowerState('someGuid', 4, 2, new Date(), 'someTenantId') + + expect(result).toBe(false) + }) + + it('should reject when the power state update fails so the caller can back off', async () => { + collection.updateOne.mockRejectedValue(new Error('mongo is down')) + + await expect(mongoDeviceTable.updatePowerState('someGuid', 4, 2, new Date(), 'someTenantId')).rejects.toThrow( + 'mongo is down' + ) + }) + it('should clear instance status', async () => { collection.updateMany.mockResolvedValue({ modifiedCount: 5 } as any) diff --git a/src/data/mongo/collections/device.ts b/src/data/mongo/collections/device.ts index 9ec1f7747..70934c582 100644 --- a/src/data/mongo/collections/device.ts +++ b/src/data/mongo/collections/device.ts @@ -96,6 +96,21 @@ export class MongoDeviceTable implements IDeviceTable { return this.collection.find({ hostname, tenantId }).toArray() as unknown as WithId[] } + async updatePowerState( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId = '' + ): Promise { + const result = await this.collection.updateOne( + { guid, tenantId }, + { $set: { powerState, osPowerSavingState, powerStateUpdatedAt: updatedAt } } + ) + // matchedCount, not modifiedCount: re-writing an unchanged power state is a successful no-op + return result.matchedCount > 0 + } + async clearInstanceStatus(mpsInstance: string): Promise { const result = await this.collection.updateMany( { mpsInstance }, diff --git a/src/data/postgres/tables/device.test.ts b/src/data/postgres/tables/device.test.ts index 49e1a54c9..1e00a4bfb 100644 --- a/src/data/postgres/tables/device.test.ts +++ b/src/data/postgres/tables/device.test.ts @@ -67,7 +67,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -105,7 +108,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -139,7 +145,10 @@ describe('device tests', () => { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1`, ['4c4c4544-004b-4210-8033-b6c04f504633'] @@ -202,7 +211,10 @@ describe('device tests', () => { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1 and tenantid = $2`, ['4c4c4544-004b-4210-8033-b6c04f504633', 'tenantId'] @@ -235,7 +247,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE hostname = $1 and tenantid = $2`, ['hostname', 'tenantId'] @@ -259,7 +274,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE hostname = $1 and tenantid = $2`, ['hostname', ''] @@ -317,7 +335,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags @> $1 and tenantId = $4 ORDER BY guid @@ -357,7 +378,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags && $1 and tenantId = $4 ORDER BY guid @@ -671,6 +695,71 @@ describe('device tests', () => { expect(mpsError).toBeInstanceOf(MPSValidationError) }) + test('should leave the power state columns alone when updating a device', async () => { + // both writers of this row must keep disjoint SET lists or one clobbers the other + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + querySpy.mockResolvedValueOnce({ rows: [{}], command: '', fields: null, rowCount: 1, oid: 0 }) + await deviceTable.update({ guid: '4c4c4544-004b-4210-8033-b6c04f504633', tenantId: '' } as Device) + const sql = querySpy.mock.calls[0][0] + expect(sql).toContain('UPDATE devices') + expect(sql).not.toContain('powerstate') + expect(sql).not.toContain('ospowersavingstate') + expect(sql).not.toContain('powerstateupdatedat') + }) + + test('should get true when power state is updated', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + const result = await deviceTable.updatePowerState( + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + 'tenantId' + ) + expect(result).toBe(true) + expect(querySpy).toHaveBeenCalledTimes(1) + expect(querySpy).toHaveBeenCalledWith( + ` + UPDATE devices + SET powerstate=$2, ospowersavingstate=$3, powerstateupdatedat=$4 + WHERE guid=$1 and tenantid = $5`, + [ + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + 'tenantId' + ] + ) + }) + + test('should default to an empty tenantId when the power state update omits it', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + await deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, updatedAt) + expect(querySpy).toHaveBeenCalledWith(expect.any(String), [ + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + '' + ]) + }) + + test('should get false when no device matches the power state update', async () => { + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 0, oid: 0 }) + const result = await deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, new Date()) + expect(result).toBe(false) + }) + + test('should reject when the power state update fails so the caller can back off', async () => { + querySpy.mockRejectedValueOnce(new Error('db is down')) + await expect( + deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, new Date()) + ).rejects.toThrow('db is down') + }) + test('should get true when device connection status update', async () => { querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) const result = await deviceTable.clearInstanceStatus('localhost') diff --git a/src/data/postgres/tables/device.ts b/src/data/postgres/tables/device.ts index d5bede3a7..54faf5057 100644 --- a/src/data/postgres/tables/device.ts +++ b/src/data/postgres/tables/device.ts @@ -48,7 +48,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -100,7 +103,10 @@ export class DeviceTable implements IDeviceTable { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1 and tenantid = $2` let params = [id, tenantId] @@ -118,7 +124,10 @@ export class DeviceTable implements IDeviceTable { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1` params = [id] @@ -140,7 +149,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE ${columnName} = $1 and tenantid = $2`, [queryValue, tenantId] @@ -178,7 +190,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags @> $1 and tenantId = $4 ORDER BY guid @@ -204,7 +219,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags && $1 and tenantId = $4 ORDER BY guid @@ -307,6 +325,38 @@ export class DeviceTable implements IDeviceTable { } } + /** + * @description Update the cached power state for a device + * @param {string} guid + * @param {number} powerState DMTF power state value read from the device + * @param {number} osPowerSavingState OS power saving state read from the device + * @param {Date} updatedAt time the power state was read + * @param {string} [tenantId] tenant the device belongs to + * @returns {boolean} Return true when a device row matched and was updated + */ + async updatePowerState( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId = '' + ): Promise { + const results = await this.db.query( + ` + UPDATE devices + SET powerstate=$2, ospowersavingstate=$3, powerstateupdatedat=$4 + WHERE guid=$1 and tenantid = $5`, + [ + guid, + powerState, + osPowerSavingState, + updatedAt, + tenantId + ] + ) + return results.rowCount > 0 + } + /** * @description Clear the mpsInstance for associated devices before process exit * @param {string} mpsInstance diff --git a/src/interfaces/IDeviceTable.ts b/src/interfaces/IDeviceTable.ts index 9c32712dd..b7a1ef6bf 100644 --- a/src/interfaces/IDeviceTable.ts +++ b/src/interfaces/IDeviceTable.ts @@ -18,5 +18,12 @@ export interface IDeviceTable extends ITable { ) => Promise getByFriendlyName: (hostname: string, tenantId?: string) => Promise getByHostname: (hostname: string, tenantId?: string) => Promise + updatePowerState: ( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId?: string + ) => Promise clearInstanceStatus: (mpsInstance: string) => Promise } diff --git a/src/models/models.ts b/src/models/models.ts index 64477b484..c487ecff4 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -22,6 +22,9 @@ export interface Device { lastSeen?: Date lastDisconnected?: Date deviceInfo?: DeviceInfo + powerState?: number + osPowerSavingState?: number + powerStateUpdatedAt?: Date } export interface DeviceInfo { fwVersion: string diff --git a/src/routes/devices/update.test.ts b/src/routes/devices/update.test.ts index 91622192d..0eb7edc62 100644 --- a/src/routes/devices/update.test.ts +++ b/src/routes/devices/update.test.ts @@ -49,6 +49,40 @@ describe('update', () => { expect(endSpy).toHaveBeenCalled() }) + it('should not let the request body overwrite server-owned power state fields', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + const stored = { + guid, + friendlyName: 'before', + powerState: 4, + osPowerSavingState: 2, + powerStateUpdatedAt: updatedAt + } as any + const updateFn = vi.fn().mockReturnValue(stored) + const req = { + db: { + devices: { + getById: vi.fn().mockReturnValue(stored), + update: updateFn + } + }, + body: { + guid, + friendlyName: 'after', + powerState: 2, + osPowerSavingState: 0, + powerStateUpdatedAt: new Date('2000-01-01T00:00:00.000Z') + } + } + await updateDevice(req as any, res as any) + const persisted = updateFn.mock.calls[0][0] + expect(persisted.friendlyName).toBe('after') + expect(persisted.powerState).toBe(4) + expect(persisted.osPowerSavingState).toBe(2) + expect(persisted.powerStateUpdatedAt).toBe(updatedAt) + expect(statusSpy).toHaveBeenCalledWith(200) + }) + it('should set status to 200 if getById gets a result', async () => { const device = {} as any diff --git a/src/routes/devices/update.ts b/src/routes/devices/update.ts index 84465924f..4eff8775b 100644 --- a/src/routes/devices/update.ts +++ b/src/routes/devices/update.ts @@ -17,7 +17,9 @@ export async function updateDevice(req: Request, res: Response): Promise { .json({ error: 'NOT FOUND', message: `Device ID ${guid} not found` }) .end() } else { - device = { ...device, ...req.body } + // power state is server-owned, so it never comes from the request body + const { powerState, osPowerSavingState, powerStateUpdatedAt, ...updatable } = req.body + device = { ...device, ...updatable } const results = await req.db.devices.update(device) res.status(200).json(results).end() }