Skip to content
Merged
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
27 changes: 25 additions & 2 deletions backend/gradient-web/src/endpoints/tasks/evaluations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ use gradient_types::input::{hex_to_vec, vec_to_hex};
use gradient_types::*;
use sea_orm::sea_query::Query as SeaQuery;
use sea_orm::{
ColumnTrait, EntityTrait, Iterable, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
ColumnTrait, Condition, EntityTrait, Iterable, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect,
};
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap, HashSet};
Expand Down Expand Up @@ -373,6 +374,15 @@ pub async fn get_task_evaluations(

let mut query = EEvaluation::find().filter(CEvaluation::Task.eq(task.id));

if let Some(before) = params.before {
let cursor = EEvaluation::find_by_id(before)
.filter(CEvaluation::Task.eq(task.id))
.one(&state.web_db)
.await?
.or_not_found("Evaluation")?;
query = query.filter(older_than(&cursor));
}

if let Some(commit) = params.commit.as_deref() {
let hash = hex_to_vec(commit)
.ok()
Expand All @@ -398,7 +408,9 @@ pub async fn get_task_evaluations(
query = query.filter(CEvaluation::Status.is_in(parse_status_filter(status)?));
}

let query = query.order_by_desc(CEvaluation::CreatedAt);
let query = query
.order_by_desc(CEvaluation::CreatedAt)
.order_by_desc(CEvaluation::Id);

let evaluations = match attr {
Some(attr) => query
Expand Down Expand Up @@ -501,13 +513,24 @@ pub async fn get_task_details(
#[derive(Deserialize, Debug, Default)]
pub struct EvaluationsQuery {
pub limit: Option<u64>,
pub before: Option<EvaluationId>,
pub commit: Option<String>,
pub status: Option<String>,
pub attr: Option<String>,
}

const COMMIT_HASH_BYTES: usize = 20;

fn older_than(cursor: &MEvaluation) -> Condition {
Condition::any()
.add(CEvaluation::CreatedAt.lt(cursor.created_at))
.add(
Condition::all()
.add(CEvaluation::CreatedAt.eq(cursor.created_at))
.add(CEvaluation::Id.lt(cursor.id)),
)
}

const ATTR_SCAN_LIMIT: u64 = 1000;

fn parse_attr_filter(attr: &str) -> WebResult<&str> {
Expand Down
59 changes: 58 additions & 1 deletion backend/gradient-web/tests/evaluations_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use gradient_entity::{commit, evaluation, ids::*, project, task};
use gradient_test_support::fixtures::{commit_id, project_id, task_id, test_date, user, user_id};
use gradient_test_support::web::{live_session, make_test_server, make_token};
use gradient_types::{ConcurrencyPolicy, SessionId};
use sea_orm::{DatabaseBackend, MockDatabase};
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Statement};
use serde_json::Value;

const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
Expand Down Expand Up @@ -222,3 +222,60 @@ async fn attr_filter_matches_queued_evaluation() {
assert_eq!(rows[0]["wildcard"], "packages.*.*");
assert_eq!(rows[0]["commit"], COMMIT_HEX);
}

fn statements(db: DatabaseConnection) -> Vec<Statement> {
db.into_transaction_log()
.iter()
.flat_map(|t| t.statements().to_vec())
.collect()
}

#[tokio::test]
async fn before_an_evaluation_of_another_task_is_not_found() {
let session_id = SessionId::now_v7();
let db = base_db(session_id).append_query_results([Vec::<evaluation::Model>::new()]);
let server = make_test_server(db.into_connection());

let res = server
.get(&format!("{URL}?before={}", EvaluationId::now_v7()))
.add_header(
"Authorization",
format!("Bearer {}", make_token(session_id)),
)
.await;

res.assert_status_not_found();
}

#[tokio::test]
async fn before_pages_to_evaluations_older_than_the_cursor() {
let session_id = SessionId::now_v7();
let cursor = eval_row(EvaluationId::now_v7(), "*", EvaluationStatus::Queued);
let db = base_db(session_id)
.append_query_results([vec![cursor.clone()]])
.append_query_results([Vec::<evaluation::Model>::new()])
.into_connection();
let server = make_test_server(db.clone());

let res = server
.get(&format!("{URL}?before={}&limit=10", cursor.id))
.add_header(
"Authorization",
format!("Bearer {}", make_token(session_id)),
)
.await;

res.assert_status_ok();
drop(server);
let page = statements(db).pop().expect("page query");
assert!(
page.sql.contains(r#""evaluation"."created_at" < $"#),
"page must stop at the cursor: {}",
page.sql
);
assert!(
page.sql.contains(r#""evaluation"."id" < $"#),
"evaluations created at the same time must page by id: {}",
page.sql
);
}
10 changes: 10 additions & 0 deletions docs/gradient-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2767,6 +2767,16 @@ paths:
schema:
type: integer
minimum: 1
- name: before
in: query
required: false
description: >-
Evaluation of this task to page back from. Only older evaluations
come back. Pass the oldest id of a page to load the next page. Ids of
other tasks give `404`.
schema:
type: string
format: uuid
- name: commit
in: query
required: false
Expand Down
12 changes: 10 additions & 2 deletions frontend/packages/ui/src/ui/in-view/in-view.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@ type Callback = (entries: Partial<IntersectionObserverEntry>[]) => void;
class FakeObserver {
static live: FakeObserver[] = [];
observed: Element[] = [];
constructor(readonly callback: Callback) { FakeObserver.live.push(this); }
constructor(readonly callback: Callback, readonly options?: IntersectionObserverInit) { FakeObserver.live.push(this); }
observe(el: Element) { this.observed.push(el); }
disconnect() { FakeObserver.live = FakeObserver.live.filter(o => o !== this); }
}

@Component({
standalone: true,
imports: [InViewDirective],
template: `<div class="sentinel" (grInView)="seen.set(seen() + 1)" [grInViewKey]="key()"></div>`,
template: `<div class="sentinel" (grInView)="seen.set(seen() + 1)" [grInViewKey]="key()" [grInViewMargin]="margin()"></div>`,
})
class HostComponent {
seen = signal(0);
key = signal(0);
margin = signal('200px');
}

function render() {
Expand Down Expand Up @@ -62,4 +63,11 @@ describe('InViewDirective', () => {
FakeObserver.live[0].callback([{ isIntersecting: true }]);
expect(fixture.componentInstance.seen()).toBe(1);
});

it('fires early by the margin a list asks for', () => {
const fixture = render();
fixture.componentInstance.margin.set('100%');
fixture.detectChanges();
expect(FakeObserver.live[0].options?.rootMargin).toBe('100%');
});
});
4 changes: 3 additions & 1 deletion frontend/packages/ui/src/ui/in-view/in-view.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ import { Directive, ElementRef, effect, inject, input, output } from '@angular/c
export class InViewDirective {
grInView = output<void>();
grInViewKey = input<unknown>();
grInViewMargin = input('200px');

private host = inject<ElementRef<Element>>(ElementRef);

constructor() {
effect((onCleanup) => {
this.grInViewKey();
const rootMargin = this.grInViewMargin();
if (typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
(entries) => entries.some((e) => e.isIntersecting) && this.grInView.emit(),
{ rootMargin: '200px' },
{ rootMargin },
);
observer.observe(this.host.nativeElement);
onCleanup(() => observer.disconnect());
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/ui/src/ui/row-list/row.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@

.row-name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
gap: $spacing-xs $spacing-sm;
color: var(--gr-text-primary);
font-size: $font-size-md;
}
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/app/core/services/tasks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ export class TasksService {
return this.api.get<PaginatedEntryPoints>(`tasks/${project}/${task}/entry-points${query}`);
}

getEvaluations(project: string, task: string, limit?: number): Observable<EvaluationSummary[]> {
const q = limit ? `?limit=${limit}` : '';
getEvaluations(project: string, task: string, limit?: number, before?: string): Observable<EvaluationSummary[]> {
const params: string[] = [];
if (limit) params.push(`limit=${limit}`);
if (before) params.push(`before=${before}`);
const q = params.length ? `?${params.join('&')}` : '';
return this.api.get<EvaluationSummary[]>(`tasks/${project}/${task}/evaluations${q}`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,21 +217,7 @@
</gr-form-field>
<gr-form-field label="Permissions">
<gr-label-help slot="label" doc="ui/members-and-roles/#cache-roles" title="Cache permissions" />
<div class="permissions-list">
@for (perm of availablePermissions(); track perm.id) {
<div class="permission-row">
<gr-checkbox
[inputId]="'perm-' + perm.id"
[label]="permissionLabel(perm.id)"
[(ngModel)]="roleForm.permissions[perm.id]"
[binary]="true"
/>
<gr-badge [severity]="perm.mutating ? 'warning' : 'success'">
{{ perm.mutating ? 'mutating' : 'read-only' }}
</gr-badge>
</div>
}
</div>
<app-permission-picker [permissions]="availablePermissions()" [(selection)]="roleForm.permissions" />
</gr-form-field>
</div>
<div grDialogFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
AutoCompleteComponent,
BadgeComponent,
ButtonComponent,
CheckboxComponent,
DialogComponent,
EmptyStateComponent,
FormFieldComponent,
Expand All @@ -28,7 +27,7 @@ import {
SelectComponent,
SettingsSectionComponent,
} from '@gradient/ui/ui';
import { LabelHelpComponent } from '@shared/ui';
import { LabelHelpComponent, PermissionPickerComponent } from '@shared/ui';
import { TeamGrantsComponent } from '@features/teams/team-grants/team-grants.component';
import { WritableDirective, ManagedDisableDirective } from '@shared/access';
import { injectCacheAccess } from '@core/resolvers/inject-access';
Expand All @@ -44,6 +43,7 @@ interface RoleFormState {
selector: 'app-cache-members-roles',
standalone: true,
imports: [
PermissionPickerComponent,
TeamGrantsComponent,
LabelHelpComponent,
CommonModule,
Expand All @@ -53,7 +53,6 @@ interface RoleFormState {
ButtonComponent,
InputDirective,
AutoCompleteComponent,
CheckboxComponent,
LoadingSpinnerComponent,
WritableDirective,
ManagedDisableDirective,
Expand All @@ -69,7 +68,6 @@ interface RoleFormState {
],
templateUrl: './cache-members-roles.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './cache-members-roles.component.scss',
})
export class CacheMembersRolesComponent implements OnInit {
private route = inject(ActivatedRoute);
Expand Down Expand Up @@ -307,7 +305,6 @@ export class CacheMembersRolesComponent implements OnInit {
});
}

readonly permissionLabel = permissionLabel;

rolePermissionLabel(role: CacheRole): string {
if (role.permissions.length === 0) return 'No permissions';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,21 +217,7 @@
</gr-form-field>
<gr-form-field label="Permissions">
<gr-label-help slot="label" doc="ui/members-and-roles/#project-roles" title="Project permissions" />
<div class="permissions-list">
@for (perm of availablePermissions(); track perm.id) {
<div class="permission-row">
<gr-checkbox
[inputId]="'perm-' + perm.id"
[label]="permissionLabel(perm.id)"
[(ngModel)]="roleForm.permissions[perm.id]"
[binary]="true"
/>
<gr-badge [severity]="perm.mutating ? 'warning' : 'success'">
{{ perm.mutating ? 'mutating' : 'read-only' }}
</gr-badge>
</div>
}
</div>
<app-permission-picker [permissions]="availablePermissions()" [(selection)]="roleForm.permissions" />
</gr-form-field>
</div>
<div grDialogFooter>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
AutoCompleteComponent,
BadgeComponent,
ButtonComponent,
CheckboxComponent,
DialogComponent,
EmptyStateComponent,
FormFieldComponent,
Expand All @@ -33,7 +32,7 @@ import {
SelectComponent,
SettingsSectionComponent,
} from '@gradient/ui/ui';
import { LabelHelpComponent } from '@shared/ui';
import { LabelHelpComponent, PermissionPickerComponent } from '@shared/ui';
import { TeamGrantsComponent } from '@features/teams/team-grants/team-grants.component';
import { WritableDirective, ManagedDisableDirective } from '@shared/access';
import { AccessState, PendingInvitation } from '@core/models';
Expand All @@ -48,6 +47,7 @@ interface RoleFormState {
selector: 'app-members-roles',
standalone: true,
imports: [
PermissionPickerComponent,
TeamGrantsComponent,
LabelHelpComponent,
CommonModule,
Expand All @@ -57,7 +57,6 @@ interface RoleFormState {
ButtonComponent,
InputDirective,
AutoCompleteComponent,
CheckboxComponent,
LoadingSpinnerComponent,
WritableDirective,
ManagedDisableDirective,
Expand All @@ -73,7 +72,6 @@ interface RoleFormState {
],
templateUrl: './members-roles.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './members-roles.component.scss',
})
export class MembersRolesComponent implements OnInit {
private route = inject(ActivatedRoute);
Expand Down Expand Up @@ -319,7 +317,6 @@ export class MembersRolesComponent implements OnInit {
});
}

readonly permissionLabel = permissionLabel;

rolePermissionLabel(role: ProjectRole): string {
if (role.permissions.length === 0) return 'No permissions';
Expand Down
Loading
Loading