Skip to content

Commit 87c2e46

Browse files
authored
iris: add generic query API to controller (#3649)
Add a raw SQL query option to the controller. We'll roll this out to the dashboard in future commits.
1 parent 6e702d8 commit 87c2e46

File tree

15 files changed

+758
-5
lines changed

15 files changed

+758
-5
lines changed

lib/iris/dashboard/src/App.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ const TABS: Tab[] = [
1515
{ key: 'fleet', label: 'Workers', to: '/fleet' },
1616
{ key: 'endpoints', label: 'Endpoints', to: '/endpoints' },
1717
{ key: 'autoscaler', label: 'Autoscaler', to: '/autoscaler' },
18-
{ key: 'status', label: 'Status', to: '/status' },
1918
{ key: 'transactions', label: 'Transactions', to: '/transactions' },
2019
{ key: 'account', label: 'Account', to: '/account' },
20+
{ key: 'query', label: 'Query Explorer', to: '/query' },
21+
{ key: 'status', label: 'Status', to: '/status' },
2122
]
2223
2324
const PATH_TO_TAB: Record<string, string> = {
@@ -26,9 +27,10 @@ const PATH_TO_TAB: Record<string, string> = {
2627
'/fleet': 'fleet',
2728
'/endpoints': 'endpoints',
2829
'/autoscaler': 'autoscaler',
29-
'/status': 'status',
3030
'/transactions': 'transactions',
3131
'/account': 'account',
32+
'/query': 'query',
33+
'/status': 'status',
3234
}
3335
3436
const activeTab = computed(() => {
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import { executeRawQuery, parseRows } from '@/composables/useQuery'
4+
import type { ColumnMeta } from '@/types/rpc'
5+
6+
interface QueryTemplate {
7+
label: string
8+
description: string
9+
sql: string
10+
}
11+
12+
const QUERY_TEMPLATES: QueryTemplate[] = [
13+
{
14+
label: 'All Jobs',
15+
description: 'List all jobs ordered by submission time',
16+
sql: 'SELECT job_id, user_id, state, submitted_at_ms, started_at_ms, finished_at_ms, num_tasks, error FROM jobs ORDER BY submitted_at_ms DESC LIMIT 100',
17+
},
18+
{
19+
label: 'Running Tasks',
20+
description: 'Tasks currently in running state',
21+
sql: 'SELECT task_id, job_id, state, started_at_ms, failure_count, preemption_count FROM tasks WHERE state = 3 ORDER BY started_at_ms DESC LIMIT 100',
22+
},
23+
{
24+
label: 'Worker Status',
25+
description: 'All workers with health and resource info',
26+
sql: 'SELECT worker_id, address, healthy, active, consecutive_failures, last_heartbeat_ms, committed_gpu, committed_tpu FROM workers ORDER BY last_heartbeat_ms DESC',
27+
},
28+
{
29+
label: 'Jobs per User',
30+
description: 'Job count grouped by user and state',
31+
sql: 'SELECT user_id, state, COUNT(*) as job_count FROM jobs GROUP BY user_id, state ORDER BY user_id ASC',
32+
},
33+
]
34+
35+
const sqlInput = ref(QUERY_TEMPLATES[0].sql)
36+
37+
const columns = ref<ColumnMeta[]>([])
38+
const rows = ref<Record<string, unknown>[]>([])
39+
const loading = ref(false)
40+
const error = ref<string | null>(null)
41+
const hasExecuted = ref(false)
42+
43+
const PAGE_SIZE = 25
44+
const currentPage = ref(0)
45+
46+
const totalPages = computed(() =>
47+
Math.max(1, Math.ceil(rows.value.length / PAGE_SIZE))
48+
)
49+
50+
const paginatedRows = computed(() => {
51+
const start = currentPage.value * PAGE_SIZE
52+
return rows.value.slice(start, start + PAGE_SIZE)
53+
})
54+
55+
function applyTemplate(template: QueryTemplate) {
56+
sqlInput.value = template.sql
57+
}
58+
59+
async function execute() {
60+
loading.value = true
61+
error.value = null
62+
hasExecuted.value = true
63+
currentPage.value = 0
64+
columns.value = []
65+
rows.value = []
66+
67+
try {
68+
const response = await executeRawQuery({ sql: sqlInput.value })
69+
columns.value = response.columns
70+
rows.value = parseRows(response.columns, response.rows)
71+
} catch (e) {
72+
error.value = e instanceof Error ? e.message : String(e)
73+
} finally {
74+
loading.value = false
75+
}
76+
}
77+
78+
function goToPage(page: number) {
79+
if (page < 0 || page >= totalPages.value) return
80+
currentPage.value = page
81+
}
82+
83+
function formatCellValue(value: unknown): string {
84+
if (value === null || value === undefined) return '\u2014'
85+
if (typeof value === 'object') return JSON.stringify(value)
86+
return String(value)
87+
}
88+
89+
function handleKeydown(event: KeyboardEvent) {
90+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
91+
execute()
92+
}
93+
}
94+
</script>
95+
96+
<template>
97+
<div class="space-y-4">
98+
<!-- Templates -->
99+
<div class="flex items-center gap-2 flex-wrap">
100+
<span class="text-xs text-text-secondary">Templates:</span>
101+
<button
102+
v-for="template in QUERY_TEMPLATES"
103+
:key="template.label"
104+
class="px-2.5 py-1 text-xs rounded border border-surface-border text-text-secondary
105+
hover:text-text hover:bg-surface-sunken transition-colors"
106+
:title="template.description"
107+
@click="applyTemplate(template)"
108+
>
109+
{{ template.label }}
110+
</button>
111+
</div>
112+
113+
<!-- SQL input -->
114+
<div>
115+
<label class="block text-xs font-medium text-text-secondary mb-1">
116+
SQL Query (SELECT only)
117+
</label>
118+
<textarea
119+
v-model="sqlInput"
120+
rows="6"
121+
class="w-full font-mono text-[13px] bg-surface-sunken text-text border border-surface-border
122+
rounded-lg px-3 py-2 resize-y focus:outline-none focus:ring-1 focus:ring-accent"
123+
placeholder="SELECT * FROM jobs LIMIT 10"
124+
spellcheck="false"
125+
@keydown="handleKeydown"
126+
/>
127+
</div>
128+
129+
<!-- Execute button -->
130+
<div class="flex items-center gap-3">
131+
<button
132+
class="px-4 py-2 text-sm font-medium text-white bg-accent rounded-lg
133+
hover:bg-accent/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
134+
:disabled="loading"
135+
@click="execute"
136+
>
137+
<span v-if="loading" class="inline-flex items-center gap-1.5">
138+
<svg class="animate-spin h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
139+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
140+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
141+
</svg>
142+
Executing...
143+
</span>
144+
<span v-else>Execute</span>
145+
</button>
146+
<span class="text-xs text-text-muted">
147+
Ctrl+Enter / Cmd+Enter to run
148+
</span>
149+
</div>
150+
151+
<!-- Error display -->
152+
<div
153+
v-if="error"
154+
class="px-4 py-3 text-sm text-status-danger bg-status-danger-bg rounded-lg border border-status-danger-border"
155+
>
156+
{{ error }}
157+
</div>
158+
159+
<!-- Results -->
160+
<div v-if="hasExecuted && !error && !loading">
161+
<!-- Results header -->
162+
<div class="flex items-center justify-between mb-2">
163+
<span class="text-xs text-text-secondary">
164+
{{ rows.length }} row{{ rows.length !== 1 ? 's' : '' }} returned
165+
<template v-if="columns.length > 0">
166+
&middot; {{ columns.length }} column{{ columns.length !== 1 ? 's' : '' }}
167+
</template>
168+
</span>
169+
</div>
170+
171+
<!-- Results table -->
172+
<div v-if="rows.length > 0" class="overflow-x-auto border border-surface-border rounded-lg">
173+
<table class="w-full border-collapse">
174+
<thead>
175+
<tr class="border-b border-surface-border bg-surface-sunken">
176+
<th
177+
v-for="col in columns"
178+
:key="col.name"
179+
class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-text-secondary whitespace-nowrap"
180+
>
181+
{{ col.name }}
182+
</th>
183+
</tr>
184+
</thead>
185+
<tbody>
186+
<tr
187+
v-for="(row, rowIdx) in paginatedRows"
188+
:key="rowIdx"
189+
class="border-b border-surface-border-subtle hover:bg-surface-raised transition-colors"
190+
>
191+
<td
192+
v-for="col in columns"
193+
:key="col.name"
194+
class="px-3 py-2 text-[13px] font-mono whitespace-nowrap max-w-xs truncate"
195+
:title="formatCellValue(row[col.name])"
196+
>
197+
{{ formatCellValue(row[col.name]) }}
198+
</td>
199+
</tr>
200+
</tbody>
201+
</table>
202+
203+
<!-- Pagination -->
204+
<div
205+
v-if="totalPages > 1"
206+
class="flex items-center justify-between px-3 py-2 text-xs text-text-secondary border-t border-surface-border"
207+
>
208+
<span>
209+
{{ currentPage * PAGE_SIZE + 1 }}–{{ Math.min((currentPage + 1) * PAGE_SIZE, rows.length) }}
210+
of {{ rows.length }}
211+
</span>
212+
<div class="flex items-center gap-1">
213+
<button
214+
:disabled="currentPage === 0"
215+
class="px-2 py-1 rounded hover:bg-surface-raised disabled:opacity-30 disabled:cursor-not-allowed"
216+
@click="goToPage(currentPage - 1)"
217+
>
218+
Prev
219+
</button>
220+
<span class="px-2 font-mono">{{ currentPage + 1 }} / {{ totalPages }}</span>
221+
<button
222+
:disabled="currentPage >= totalPages - 1"
223+
class="px-2 py-1 rounded hover:bg-surface-raised disabled:opacity-30 disabled:cursor-not-allowed"
224+
@click="goToPage(currentPage + 1)"
225+
>
226+
Next
227+
</button>
228+
</div>
229+
</div>
230+
</div>
231+
232+
<!-- Empty results -->
233+
<div
234+
v-else
235+
class="flex items-center justify-center py-12 text-text-muted text-sm border border-surface-border rounded-lg"
236+
>
237+
Query returned no rows
238+
</div>
239+
</div>
240+
</div>
241+
</template>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Composable and helpers for the raw query API.
3+
*
4+
* Wraps the ExecuteRawQuery RPC. Row data comes back as JSON-encoded arrays;
5+
* parseRows() converts them to keyed objects.
6+
*/
7+
import { controllerRpcCall } from './useRpc'
8+
import type { ColumnMeta, RawQueryResponse } from '@/types/rpc'
9+
10+
export type { ColumnMeta, RawQueryResponse }
11+
12+
export interface RawQueryRequest {
13+
sql: string
14+
}
15+
16+
/** Execute a raw SQL query (admin-only). */
17+
export function executeRawQuery(request: RawQueryRequest): Promise<RawQueryResponse> {
18+
return controllerRpcCall<RawQueryResponse>('ExecuteRawQuery', request as unknown as Record<string, unknown>)
19+
}
20+
21+
/**
22+
* Parse JSON-encoded row arrays into keyed objects using column metadata.
23+
*
24+
* Each element of `rows` is a JSON array of scalar values aligned with
25+
* `columns`. This function zips them into Record<string, unknown> objects
26+
* keyed by column name, which is much more ergonomic for template rendering.
27+
*/
28+
export function parseRows(columns: ColumnMeta[], rows: string[]): Record<string, unknown>[] {
29+
const names = columns.map((c) => c.name)
30+
return rows.map((row) => {
31+
const values = JSON.parse(row) as unknown[]
32+
const record: Record<string, unknown> = {}
33+
for (let i = 0; i < names.length; i++) {
34+
record[names[i]] = values[i]
35+
}
36+
return record
37+
})
38+
}

lib/iris/dashboard/src/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const routes = [
3838
path: '/account',
3939
component: () => import('./components/controller/AccountTab.vue'),
4040
},
41+
{
42+
path: '/query',
43+
component: () => import('./components/controller/QueryExplorerTab.vue'),
44+
},
4145
{
4246
path: '/system/controller/threads',
4347
component: () => import('./components/controller/ThreadDump.vue'),

lib/iris/dashboard/src/types/rpc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,16 @@ export interface ApiKeyInfo {
427427
export interface ListApiKeysResponse {
428428
keys: ApiKeyInfo[]
429429
}
430+
431+
// -- Generic Query API --
432+
433+
/** Column metadata describing a single column in a query result set. */
434+
export interface ColumnMeta {
435+
name: string
436+
type: string
437+
}
438+
439+
export interface RawQueryResponse {
440+
columns: ColumnMeta[]
441+
rows: string[]
442+
}

lib/iris/dashboard/src/types/status.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,30 @@ export type TaskState =
3030
| 'unschedulable'
3131
| 'assigned'
3232

33+
// The DB stores state as an integer column. This maps integer values to
34+
// normalized state names so the dashboard works with both proto enum strings
35+
// (e.g. "JOB_STATE_RUNNING") and raw integer values from the query API.
36+
const STATE_INT_MAP: Record<number, string> = {
37+
0: 'unspecified',
38+
1: 'pending',
39+
2: 'building',
40+
3: 'running',
41+
4: 'succeeded',
42+
5: 'failed',
43+
6: 'killed',
44+
7: 'worker_failed',
45+
8: 'unschedulable',
46+
9: 'assigned',
47+
}
48+
3349
/**
3450
* Strip the proto enum prefix (JOB_STATE_ or TASK_STATE_) and lowercase.
35-
* Handles null, undefined, empty string, and numeric 0.
51+
* Also handles integer state values from the database.
3652
*/
3753
export function stateToName(protoState: string | number | null | undefined): string {
3854
if (protoState === null || protoState === undefined) return 'unknown'
3955
if (protoState === '') return 'unknown'
40-
if (protoState === 0) return 'unspecified'
41-
if (typeof protoState === 'number') return 'unknown'
56+
if (typeof protoState === 'number') return STATE_INT_MAP[protoState] ?? 'unknown'
4257

4358
return protoState.replace(/^(JOB_STATE_|TASK_STATE_)/, '').toLowerCase()
4459
}

0 commit comments

Comments
 (0)