Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cueweb/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com

# Optional: Loki log backend. When set, the frame log viewer queries this Loki
# server (by frame id) instead of reading the on-disk .rqlog file. Leave unset
# to use the default file-based log viewer.
# NEXT_PUBLIC_LOKI_URL=http://your-loki-host:3100

# Sentry values
SENTRY_ENVIRONMENT='development'
SENTRY_DSN = sentrydsn
Expand Down
12 changes: 12 additions & 0 deletions cueweb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,23 @@ Next is the process to install and use the CueWeb system.
SENTRY_PROJECT = sentryproject
```

- Loki log backend (optional)
- NEXT_PUBLIC_LOKI_URL
- When set, the frame log viewer queries a [Grafana Loki](https://grafana.com/oss/loki/) server for a frame's logs (by `frame_id`) instead of reading the on-disk `.rqlog` file. This mirrors CueGUI's `LokiViewPlugin` (`cuegui/cuegui/plugins/LokiViewPlugin.py`) and requires RQD to be configured to ship frame logs to Loki.
- Set it to the base URL of your Loki HTTP API, e.g. `http://your-loki-host:3100` (no trailing path; CueWeb appends `/loki/api/v1/...`). The viewer lists each frame attempt as a selectable "log version" (Loki's `session_start_time` label), newest first.
- **If `NEXT_PUBLIC_LOKI_URL` is not set, CueWeb falls back to the default file-based log viewer.** No other configuration is required to keep the existing behavior.
- Because this is a `NEXT_PUBLIC_*` variable it is read in the browser, so the Loki server must be reachable from clients and must permit cross-origin (CORS) requests from the CueWeb origin.

Example of `.env` file (`cueweb/.env.example`):

```env
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com

# Optional: Loki log backend. When set, the frame log viewer queries Loki by
# frame id instead of reading the on-disk .rqlog file. Leave unset to use the
# default file-based log viewer.
# NEXT_PUBLIC_LOKI_URL=http://your-loki-host:3100

# Sentry values
SENTRY_ENVIRONMENT='development'
SENTRY_DSN = sentrydsn
Expand Down
153 changes: 153 additions & 0 deletions cueweb/app/__tests__/loki.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// The module reads process.env.NEXT_PUBLIC_LOKI_URL at import time via a
// getter, so we set the env var per-test and re-import with a fresh registry.
const ORIGINAL_ENV = process.env;

function loadLoki(lokiUrl?: string) {
jest.resetModules();
process.env = { ...ORIGINAL_ENV };
if (lokiUrl === undefined) {
delete process.env.NEXT_PUBLIC_LOKI_URL;
} else {
process.env.NEXT_PUBLIC_LOKI_URL = lokiUrl;
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('@/lib/loki');
}

afterEach(() => {
process.env = ORIGINAL_ENV;
jest.restoreAllMocks();
});

function mockFetch(jsonByUrl: (url: string) => unknown, ok = true, status = 200) {
const fn = jest.fn(async (url: string) => ({
ok,
status,
json: async () => jsonByUrl(url),
}));
// @ts-expect-error - assigning a test double to the global fetch.
global.fetch = fn;
return fn;
}

describe('isLokiEnabled / getLokiUrl', () => {
it('reports disabled when the env var is unset', () => {
const loki = loadLoki(undefined);
expect(loki.isLokiEnabled()).toBe(false);
expect(loki.getLokiUrl()).toBe('');
});

it('reports disabled for a blank/whitespace value', () => {
const loki = loadLoki(' ');
expect(loki.isLokiEnabled()).toBe(false);
});

it('reports enabled and strips trailing slashes', () => {
const loki = loadLoki('http://loki:3100/');
expect(loki.isLokiEnabled()).toBe(true);
expect(loki.getLokiUrl()).toBe('http://loki:3100');
});
});

describe('getFrameLogVersions', () => {
it('returns attempts newest-first with human-readable labels', async () => {
const loki = loadLoki('http://loki:3100');
const fetchFn = mockFetch(() => ({
status: 'success',
data: ['1700000000', '1700009999', '1700005000'],
}));

const versions = await loki.getFrameLogVersions('frame-123', 1699999999);

// Sorted descending by the unix timestamp.
expect(versions.map((v: any) => v.sessionStartTime)).toEqual([
'1700009999',
'1700005000',
'1700000000',
]);
// Each gets a formatted label (not just the raw number).
expect(versions[0].label).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);

// Hits the label_values endpoint with a frame_id selector + start bound.
const calledUrl = fetchFn.mock.calls[0][0] as string;
expect(calledUrl).toContain('/loki/api/v1/label/session_start_time/values');
expect(decodeURIComponent(calledUrl)).toContain('{frame_id="frame-123"}');
expect(calledUrl).toContain('start=1699999999000000000');
});

it('returns an empty list when frameId is missing (no fetch)', async () => {
const loki = loadLoki('http://loki:3100');
const fetchFn = mockFetch(() => ({ status: 'success', data: [] }));
expect(await loki.getFrameLogVersions('')).toEqual([]);
expect(fetchFn).not.toHaveBeenCalled();
});
});

describe('getFrameLogLines', () => {
it('concatenates the line values across streams', async () => {
const loki = loadLoki('http://loki:3100');
const fetchFn = mockFetch(() => ({
status: 'success',
data: {
resultType: 'streams',
result: [
{
stream: { frame_id: 'frame-123' },
values: [
['1700000001000000000', 'line one'],
['1700000002000000000', 'line two'],
],
},
],
},
}));

const text = await loki.getFrameLogLines('frame-123', '1700000000');

expect(text).toBe('line one\nline two');
const calledUrl = fetchFn.mock.calls[0][0] as string;
expect(calledUrl).toContain('/loki/api/v1/query_range');
// URLSearchParams form-encodes spaces as '+'; Loki decodes them back.
expect(decodeURIComponent(calledUrl).replace(/\+/g, ' ')).toContain(
'{session_start_time="1700000000", frame_id="frame-123"}',
);
expect(calledUrl).toContain('direction=forward');
});

it('returns an empty string when Loki has no streams', async () => {
const loki = loadLoki('http://loki:3100');
mockFetch(() => ({ status: 'success', data: { result: [] } }));
expect(await loki.getFrameLogLines('frame-123', '1700000000')).toBe('');
});

it('throws when the Loki request fails', async () => {
const loki = loadLoki('http://loki:3100');
mockFetch(() => ({}), false, 502);
await expect(
loki.getFrameLogLines('frame-123', '1700000000'),
).rejects.toThrow(/Loki request failed \(502\)/);
});

it('throws when Loki is not configured', async () => {
const loki = loadLoki(undefined);
await expect(loki.getFrameLogLines('frame-123')).rejects.toThrow(
/not configured/,
);
});
});
Loading
Loading