Skip to content

Commit 92f7ba7

Browse files
[cueweb/docs] Add optional Loki backend for frame log viewing (#2447)
## Related Issues - #2456 ## Summarize your change. [cueweb] Add optional Loki backend for frame log viewing When NEXT_PUBLIC_LOKI_URL is set, the frame log viewer queries Loki by frame_id instead of reading the on-disk .rqlog file, mirroring CueGUI's LokiViewPlugin. Falls back to the file-based viewer when unset. - [x] lib/loki.ts: Loki HTTP API client (label values for frame attempts, query_range for log lines) - [x] frames/[frame-name]: branch on the env flag; new LokiLogView reuses the same Monaco editor, version dropdown, and empty/loading states - [x] Document NEXT_PUBLIC_LOKI_URL in README.md and .env.example - [x] Add lib/loki.ts unit tests [cueweb/docs] Document the optional Loki frame-log backend The frame log viewer now has two interchangeable backends: the default file-based reader (.rqlog on disk) and an optional Loki backend selected by NEXT_PUBLIC_LOKI_URL, mirroring CueGUI's Loki log viewer. Document it across all guides: - [x] quick-starts: a NEXT_PUBLIC_LOKI_URL note in the .env example and the "View Logs" step. - [x] concepts: "Frame logs (file-based and Loki)" — the two read paths and why the backend is a single deploy-time switch. - [x] getting-started: "Frame log viewing (file-based or Loki)" deploy section (build arg, browser-read, CORS, the log mount becomes optional) plus a Loki entry in the .env config block. - [x] user-guide: expand the Frame Log Viewer section to cover both backends (Loki "log versions" are frame attempts, with a Refresh button). - [x] other-guides: a feature-list entry for the optional Loki backend. - [x] reference: a NEXT_PUBLIC_LOKI_URL env-var row, a "Frame log backends" comparison table + lib/loki.ts API summary, and updated the log-viewer and volume-mount notes. - [x] tutorials: note that the viewer behaves the same on the Loki backend. - [x] developer-guide: a "Frame log backends (file-based and Loki)" section (isLokiEnabled() branch, lib/loki.ts, LokiLogView) and the FrameViewer bullet. ## LLM usage disclosure Parts of this solution's implementation were developed with assistance from Claude Opus.
1 parent 5b117ef commit 92f7ba7

14 files changed

Lines changed: 831 additions & 5 deletions

File tree

cueweb/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com
22

3+
# Optional: Loki log backend. When set, the frame log viewer queries this Loki
4+
# server (by frame id) instead of reading the on-disk .rqlog file. Leave unset
5+
# to use the default file-based log viewer.
6+
# NEXT_PUBLIC_LOKI_URL=http://your-loki-host:3100
7+
38
# Short Git SHA shown in the "About CueWeb" dialog (Help -> About CueWeb).
49
# Build-time only; CI injects it, e.g.
510
# docker build --build-arg NEXT_PUBLIC_GIT_SHA=$(git rev-parse --short HEAD) ...

cueweb/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,23 @@ Next is the process to install and use the CueWeb system.
250250
SENTRY_PROJECT = sentryproject
251251
```
252252
253+
- Loki log backend (optional)
254+
- NEXT_PUBLIC_LOKI_URL
255+
- 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.
256+
- 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.
257+
- **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.
258+
- 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.
259+
253260
Example of `.env` file (`cueweb/.env.example`):
254261
255262
```env
256263
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com
257264
265+
# Optional: Loki log backend. When set, the frame log viewer queries Loki by
266+
# frame id instead of reading the on-disk .rqlog file. Leave unset to use the
267+
# default file-based log viewer.
268+
# NEXT_PUBLIC_LOKI_URL=http://your-loki-host:3100
269+
258270
# Sentry values
259271
SENTRY_ENVIRONMENT='development'
260272
SENTRY_DSN = sentrydsn

cueweb/app/__tests__/loki.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
getFrameLogLines,
19+
getFrameLogVersions,
20+
getLokiUrl,
21+
isLokiEnabled,
22+
} from '@/lib/loki';
23+
24+
// The module reads process.env.NEXT_PUBLIC_LOKI_URL at call time (not at
25+
// import time), so each test just sets the env var before exercising the API.
26+
const ORIGINAL_LOKI_URL = process.env.NEXT_PUBLIC_LOKI_URL;
27+
28+
function setLokiUrl(lokiUrl?: string) {
29+
if (lokiUrl === undefined) {
30+
delete process.env.NEXT_PUBLIC_LOKI_URL;
31+
} else {
32+
process.env.NEXT_PUBLIC_LOKI_URL = lokiUrl;
33+
}
34+
}
35+
36+
afterEach(() => {
37+
setLokiUrl(ORIGINAL_LOKI_URL);
38+
jest.restoreAllMocks();
39+
});
40+
41+
function mockFetch(jsonByUrl: (url: string) => unknown, ok = true, status = 200) {
42+
const fn = jest.fn(async (url: string) => ({
43+
ok,
44+
status,
45+
json: async () => jsonByUrl(url),
46+
}));
47+
// @ts-expect-error - assigning a test double to the global fetch.
48+
global.fetch = fn;
49+
return fn;
50+
}
51+
52+
describe('isLokiEnabled / getLokiUrl', () => {
53+
it('reports disabled when the env var is unset', () => {
54+
setLokiUrl(undefined);
55+
expect(isLokiEnabled()).toBe(false);
56+
expect(getLokiUrl()).toBe('');
57+
});
58+
59+
it('reports disabled for a blank/whitespace value', () => {
60+
setLokiUrl(' ');
61+
expect(isLokiEnabled()).toBe(false);
62+
});
63+
64+
it('reports enabled and strips trailing slashes', () => {
65+
setLokiUrl('http://loki:3100/');
66+
expect(isLokiEnabled()).toBe(true);
67+
expect(getLokiUrl()).toBe('http://loki:3100');
68+
});
69+
});
70+
71+
describe('getFrameLogVersions', () => {
72+
it('returns attempts newest-first with human-readable labels', async () => {
73+
setLokiUrl('http://loki:3100');
74+
const fetchFn = mockFetch(() => ({
75+
status: 'success',
76+
data: ['1700000000', '1700009999', '1700005000'],
77+
}));
78+
79+
const versions = await getFrameLogVersions('frame-123', 1699999999);
80+
81+
// Sorted descending by the unix timestamp.
82+
expect(versions.map((v) => v.sessionStartTime)).toEqual([
83+
'1700009999',
84+
'1700005000',
85+
'1700000000',
86+
]);
87+
// Each gets a formatted label (not just the raw number).
88+
expect(versions[0].label).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
89+
90+
// Hits the label_values endpoint with a frame_id selector + start bound.
91+
const calledUrl = fetchFn.mock.calls[0][0] as string;
92+
expect(calledUrl).toContain('/loki/api/v1/label/session_start_time/values');
93+
expect(decodeURIComponent(calledUrl)).toContain('{frame_id="frame-123"}');
94+
expect(calledUrl).toContain('start=1699999999000000000');
95+
});
96+
97+
it('returns an empty list when frameId is missing (no fetch)', async () => {
98+
setLokiUrl('http://loki:3100');
99+
const fetchFn = mockFetch(() => ({ status: 'success', data: [] }));
100+
expect(await getFrameLogVersions('')).toEqual([]);
101+
expect(fetchFn).not.toHaveBeenCalled();
102+
});
103+
});
104+
105+
describe('getFrameLogLines', () => {
106+
it('concatenates the line values across streams', async () => {
107+
setLokiUrl('http://loki:3100');
108+
const fetchFn = mockFetch(() => ({
109+
status: 'success',
110+
data: {
111+
resultType: 'streams',
112+
result: [
113+
{
114+
stream: { frame_id: 'frame-123' },
115+
values: [
116+
['1700000001000000000', 'line one'],
117+
['1700000002000000000', 'line two'],
118+
],
119+
},
120+
],
121+
},
122+
}));
123+
124+
const text = await getFrameLogLines('frame-123', '1700000000');
125+
126+
expect(text).toBe('line one\nline two');
127+
const calledUrl = fetchFn.mock.calls[0][0] as string;
128+
expect(calledUrl).toContain('/loki/api/v1/query_range');
129+
// URLSearchParams form-encodes spaces as '+'; Loki decodes them back.
130+
expect(decodeURIComponent(calledUrl).replace(/\+/g, ' ')).toContain(
131+
'{session_start_time="1700000000", frame_id="frame-123"}',
132+
);
133+
expect(calledUrl).toContain('direction=backward');
134+
});
135+
136+
it('interleaves multiple streams in chronological order', async () => {
137+
setLokiUrl('http://loki:3100');
138+
// stdout and stderr arrive as separate streams, out of order relative to
139+
// each other. Timestamps exceed Number.MAX_SAFE_INTEGER on purpose.
140+
mockFetch(() => ({
141+
status: 'success',
142+
data: {
143+
resultType: 'streams',
144+
result: [
145+
{
146+
stream: { stream: 'stdout' },
147+
values: [
148+
['1700000001000000000', 'out-1'],
149+
['1700000003000000000', 'out-3'],
150+
],
151+
},
152+
{
153+
stream: { stream: 'stderr' },
154+
values: [
155+
['1700000002000000000', 'err-2'],
156+
['1700000004000000000', 'err-4'],
157+
],
158+
},
159+
],
160+
},
161+
}));
162+
163+
const text = await getFrameLogLines('frame-123', '1700000000');
164+
expect(text).toBe('out-1\nerr-2\nout-3\nerr-4');
165+
});
166+
167+
it('returns an empty string when Loki has no streams', async () => {
168+
setLokiUrl('http://loki:3100');
169+
mockFetch(() => ({ status: 'success', data: { result: [] } }));
170+
expect(await getFrameLogLines('frame-123', '1700000000')).toBe('');
171+
});
172+
173+
it('throws when the Loki request fails', async () => {
174+
setLokiUrl('http://loki:3100');
175+
mockFetch(() => ({}), false, 502);
176+
await expect(getFrameLogLines('frame-123', '1700000000')).rejects.toThrow(
177+
/Loki request failed \(502\)/,
178+
);
179+
});
180+
181+
it('throws when Loki is not configured', async () => {
182+
setLokiUrl(undefined);
183+
await expect(getFrameLogLines('frame-123')).rejects.toThrow(
184+
/not configured/,
185+
);
186+
});
187+
});

0 commit comments

Comments
 (0)