diff --git a/backend/gradient-web/src/endpoints/tasks/evaluations.rs b/backend/gradient-web/src/endpoints/tasks/evaluations.rs index 30c508f8c..d7c94233f 100644 --- a/backend/gradient-web/src/endpoints/tasks/evaluations.rs +++ b/backend/gradient-web/src/endpoints/tasks/evaluations.rs @@ -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}; @@ -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() @@ -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 @@ -501,6 +513,7 @@ pub async fn get_task_details( #[derive(Deserialize, Debug, Default)] pub struct EvaluationsQuery { pub limit: Option, + pub before: Option, pub commit: Option, pub status: Option, pub attr: Option, @@ -508,6 +521,16 @@ pub struct EvaluationsQuery { 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> { diff --git a/backend/gradient-web/tests/evaluations_search.rs b/backend/gradient-web/tests/evaluations_search.rs index bf1d49d23..f69564790 100644 --- a/backend/gradient-web/tests/evaluations_search.rs +++ b/backend/gradient-web/tests/evaluations_search.rs @@ -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"; @@ -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 { + 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::::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::::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 + ); +} diff --git a/docs/gradient-api.yaml b/docs/gradient-api.yaml index a8c089ea4..f030df9c2 100644 --- a/docs/gradient-api.yaml +++ b/docs/gradient-api.yaml @@ -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 diff --git a/frontend/packages/ui/src/ui/in-view/in-view.directive.spec.ts b/frontend/packages/ui/src/ui/in-view/in-view.directive.spec.ts index daeec7c47..27dfb2922 100644 --- a/frontend/packages/ui/src/ui/in-view/in-view.directive.spec.ts +++ b/frontend/packages/ui/src/ui/in-view/in-view.directive.spec.ts @@ -14,7 +14,7 @@ type Callback = (entries: Partial[]) => 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); } } @@ -22,11 +22,12 @@ class FakeObserver { @Component({ standalone: true, imports: [InViewDirective], - template: `
`, + template: `
`, }) class HostComponent { seen = signal(0); key = signal(0); + margin = signal('200px'); } function render() { @@ -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%'); + }); }); diff --git a/frontend/packages/ui/src/ui/in-view/in-view.directive.ts b/frontend/packages/ui/src/ui/in-view/in-view.directive.ts index cb09659fc..5205bd4fb 100644 --- a/frontend/packages/ui/src/ui/in-view/in-view.directive.ts +++ b/frontend/packages/ui/src/ui/in-view/in-view.directive.ts @@ -15,16 +15,18 @@ import { Directive, ElementRef, effect, inject, input, output } from '@angular/c export class InViewDirective { grInView = output(); grInViewKey = input(); + grInViewMargin = input('200px'); private host = inject>(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()); diff --git a/frontend/packages/ui/src/ui/row-list/row.component.scss b/frontend/packages/ui/src/ui/row-list/row.component.scss index 657b2c32f..f55561e92 100644 --- a/frontend/packages/ui/src/ui/row-list/row.component.scss +++ b/frontend/packages/ui/src/ui/row-list/row.component.scss @@ -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; } diff --git a/frontend/src/app/core/services/tasks.service.ts b/frontend/src/app/core/services/tasks.service.ts index 8947068c0..792e98661 100644 --- a/frontend/src/app/core/services/tasks.service.ts +++ b/frontend/src/app/core/services/tasks.service.ts @@ -64,8 +64,11 @@ export class TasksService { return this.api.get(`tasks/${project}/${task}/entry-points${query}`); } - getEvaluations(project: string, task: string, limit?: number): Observable { - const q = limit ? `?limit=${limit}` : ''; + getEvaluations(project: string, task: string, limit?: number, before?: string): Observable { + 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(`tasks/${project}/${task}/evaluations${q}`); } diff --git a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.html b/frontend/src/app/features/caches/members-roles/cache-members-roles.component.html index d18e0ea15..a571a5442 100644 --- a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.html +++ b/frontend/src/app/features/caches/members-roles/cache-members-roles.component.html @@ -217,21 +217,7 @@ -
- @for (perm of availablePermissions(); track perm.id) { -
- - - {{ perm.mutating ? 'mutating' : 'read-only' }} - -
- } -
+
diff --git a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.ts b/frontend/src/app/features/caches/members-roles/cache-members-roles.component.ts index fb310726b..28cd46973 100644 --- a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.ts +++ b/frontend/src/app/features/caches/members-roles/cache-members-roles.component.ts @@ -15,7 +15,6 @@ import { AutoCompleteComponent, BadgeComponent, ButtonComponent, - CheckboxComponent, DialogComponent, EmptyStateComponent, FormFieldComponent, @@ -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'; @@ -44,6 +43,7 @@ interface RoleFormState { selector: 'app-cache-members-roles', standalone: true, imports: [ + PermissionPickerComponent, TeamGrantsComponent, LabelHelpComponent, CommonModule, @@ -53,7 +53,6 @@ interface RoleFormState { ButtonComponent, InputDirective, AutoCompleteComponent, - CheckboxComponent, LoadingSpinnerComponent, WritableDirective, ManagedDisableDirective, @@ -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); @@ -307,7 +305,6 @@ export class CacheMembersRolesComponent implements OnInit { }); } - readonly permissionLabel = permissionLabel; rolePermissionLabel(role: CacheRole): string { if (role.permissions.length === 0) return 'No permissions'; diff --git a/frontend/src/app/features/projects/members-roles/members-roles.component.html b/frontend/src/app/features/projects/members-roles/members-roles.component.html index 185c5917a..944f3f94f 100644 --- a/frontend/src/app/features/projects/members-roles/members-roles.component.html +++ b/frontend/src/app/features/projects/members-roles/members-roles.component.html @@ -217,21 +217,7 @@ -
- @for (perm of availablePermissions(); track perm.id) { -
- - - {{ perm.mutating ? 'mutating' : 'read-only' }} - -
- } -
+
diff --git a/frontend/src/app/features/projects/members-roles/members-roles.component.scss b/frontend/src/app/features/projects/members-roles/members-roles.component.scss deleted file mode 100644 index 0f2803340..000000000 --- a/frontend/src/app/features/projects/members-roles/members-roles.component.scss +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Wavelens GmbH - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -@use '@gradient/ui/styles/variables' as *;// One permission per line: the names are long enough that two columns collide. -.permissions-list { - display: flex; - flex-direction: column; - gap: $spacing-xs; - max-height: 320px; - overflow-y: auto; - padding: $spacing-sm; - border: 1px solid var(--gr-border-subtle); - border-radius: $border-radius-sm; - background: var(--gr-surface-sunken); -} - -.permission-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: $spacing-sm; - padding: $spacing-xs; -} diff --git a/frontend/src/app/features/projects/members-roles/members-roles.component.ts b/frontend/src/app/features/projects/members-roles/members-roles.component.ts index f6794a346..384def4a2 100644 --- a/frontend/src/app/features/projects/members-roles/members-roles.component.ts +++ b/frontend/src/app/features/projects/members-roles/members-roles.component.ts @@ -20,7 +20,6 @@ import { AutoCompleteComponent, BadgeComponent, ButtonComponent, - CheckboxComponent, DialogComponent, EmptyStateComponent, FormFieldComponent, @@ -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'; @@ -48,6 +47,7 @@ interface RoleFormState { selector: 'app-members-roles', standalone: true, imports: [ + PermissionPickerComponent, TeamGrantsComponent, LabelHelpComponent, CommonModule, @@ -57,7 +57,6 @@ interface RoleFormState { ButtonComponent, InputDirective, AutoCompleteComponent, - CheckboxComponent, LoadingSpinnerComponent, WritableDirective, ManagedDisableDirective, @@ -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); @@ -319,7 +317,6 @@ export class MembersRolesComponent implements OnInit { }); } - readonly permissionLabel = permissionLabel; rolePermissionLabel(role: ProjectRole): string { if (role.permissions.length === 0) return 'No permissions'; diff --git a/frontend/src/app/features/settings/api-keys/api-keys.component.html b/frontend/src/app/features/settings/api-keys/api-keys.component.html index 5280adba9..1b67ea885 100644 --- a/frontend/src/app/features/settings/api-keys/api-keys.component.html +++ b/frontend/src/app/features/settings/api-keys/api-keys.component.html @@ -159,18 +159,7 @@ -
- @for (p of activePermissions(); track p.id) { -
- - -
- } -
+
diff --git a/frontend/src/app/features/settings/api-keys/api-keys.component.scss b/frontend/src/app/features/settings/api-keys/api-keys.component.scss index 700373ddc..b1108ffe1 100644 --- a/frontend/src/app/features/settings/api-keys/api-keys.component.scss +++ b/frontend/src/app/features/settings/api-keys/api-keys.component.scss @@ -30,21 +30,3 @@ } -.permission-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: $spacing-xs $spacing-md; -} - -.permission-row { - display: flex; - align-items: center; - gap: $spacing-sm; - padding: 2px 0; - - label { - font-family: $font-family-mono; - font-size: $font-size-sm; - cursor: pointer; - } -} diff --git a/frontend/src/app/features/settings/api-keys/api-keys.component.ts b/frontend/src/app/features/settings/api-keys/api-keys.component.ts index daa323575..85d661150 100644 --- a/frontend/src/app/features/settings/api-keys/api-keys.component.ts +++ b/frontend/src/app/features/settings/api-keys/api-keys.component.ts @@ -16,7 +16,6 @@ import { PermissionDescriptor } from '@core/models/permission.model'; import { BadgeComponent, ButtonComponent, - CheckboxComponent, CopyFieldComponent, DialogComponent, DividerComponent, @@ -35,6 +34,7 @@ import { import { ManagedDisableDirective } from '@shared/access'; import { AccessState } from '@core/models'; import { permissionLabel } from '@shared/text'; +import { PermissionPickerComponent } from '@shared/ui'; type ScopeType = 'none' | 'project' | 'cache'; @@ -47,13 +47,13 @@ interface SelectOption { selector: 'app-api-keys', standalone: true, imports: [ + PermissionPickerComponent, CommonModule, RouterModule, FormsModule, DialogComponent, ButtonComponent, InputDirective, - CheckboxComponent, DividerComponent, SelectComponent, SelectButtonComponent, @@ -298,7 +298,6 @@ export class ApiKeysComponent implements OnInit { }); } - readonly permissionLabel = permissionLabel; permissionTooltip(key: ApiKey): string { if (key.permissions.length === 0) return 'No permissions'; diff --git a/frontend/src/app/features/settings/sessions/sessions.component.html b/frontend/src/app/features/settings/sessions/sessions.component.html index bd214a846..f6858d2b5 100644 --- a/frontend/src/app/features/settings/sessions/sessions.component.html +++ b/frontend/src/app/features/settings/sessions/sessions.component.html @@ -18,7 +18,7 @@ @for (s of sessions(); track s.id) { - {{ s.user_agent || 'Unknown device' }} + {{ deviceName(s.user_agent) }} @if (s.current) { This device } diff --git a/frontend/src/app/features/settings/sessions/sessions.component.ts b/frontend/src/app/features/settings/sessions/sessions.component.ts index e689ee7f3..f70c293b7 100644 --- a/frontend/src/app/features/settings/sessions/sessions.component.ts +++ b/frontend/src/app/features/settings/sessions/sessions.component.ts @@ -8,6 +8,7 @@ import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@ang import { CommonModule } from '@angular/common'; import { UserService } from '@core/services/user.service'; import { Session } from '@core/models'; +import { deviceName } from '@shared/text'; import { BadgeComponent, ButtonComponent, @@ -38,6 +39,7 @@ import { }) export class SessionsComponent implements OnInit { private userService = inject(UserService); + protected readonly deviceName = deviceName; loading = signal(true); revokingId = signal(null); diff --git a/frontend/src/app/features/styleguide/pages/elements.component.html b/frontend/src/app/features/styleguide/pages/elements.component.html index f60701c3c..a3c1cd8ce 100644 --- a/frontend/src/app/features/styleguide/pages/elements.component.html +++ b/frontend/src/app/features/styleguide/pages/elements.component.html @@ -128,6 +128,10 @@

Allowed capabilities

+

Permission picker

+

Role and API key dialogs. Each permission says whether it can change anything.

+ +

Status icon

One SVG per icon that morphs between phases. The first render is static; reduced motion switches instantly.

diff --git a/frontend/src/app/features/styleguide/pages/elements.component.ts b/frontend/src/app/features/styleguide/pages/elements.component.ts index c18b360d7..9dc68c873 100644 --- a/frontend/src/app/features/styleguide/pages/elements.component.ts +++ b/frontend/src/app/features/styleguide/pages/elements.component.ts @@ -34,6 +34,7 @@ import { EvalStatusBadgeComponent, type BarSegment, MetricChartComponent, + PermissionPickerComponent, SegmentedBarComponent, StarButtonComponent, StatusIconComponent, @@ -57,6 +58,7 @@ function counts(c: Partial): BuildStatusCounts { CardGridComponent, RowComponent, RowListComponent, SkeletonComponent, LogoComponent, StarButtonComponent, StatusIconComponent, SegmentedBarComponent, AllowedCapabilitiesComponent, + PermissionPickerComponent, ], // The demo star toggles locally instead of writing the viewer's real stars. providers: [MessageService, { provide: StarsService, useValue: { set: () => of(true) } }], @@ -65,6 +67,13 @@ function counts(c: Partial): BuildStatusCounts { styleUrls: ['./demo.scss', './elements.component.scss'], }) export class ElementsComponent { + readonly demoPermissions = [ + { id: 'viewProject', mutating: false }, + { id: 'createTask', mutating: true }, + { id: 'deleteProject', mutating: true }, + ]; + demoSelection: Record = { viewProject: true }; + private messages = inject(MessageService); evalStatuses = [ diff --git a/frontend/src/app/features/tasks/task-detail/entry-point-groups.spec.ts b/frontend/src/app/features/tasks/task-detail/entry-point-groups.spec.ts index e0542fddf..c272e132c 100644 --- a/frontend/src/app/features/tasks/task-detail/entry-point-groups.spec.ts +++ b/frontend/src/app/features/tasks/task-detail/entry-point-groups.spec.ts @@ -22,7 +22,7 @@ describe('groupEntryPoints', () => { it('keeps a second segment that is not the entry point architecture', () => { expect(shape([ep('legacyPackages.x86_64-linux.python3Packages.requests'), ep('lib.aarch64-linux.hello')])) .toEqual([ - ['LegacyPackages', ['python3Packages.requests']], + ['Legacy Packages', ['python3Packages.requests']], ['Lib', ['aarch64-linux.hello']], ]); }); @@ -39,7 +39,7 @@ describe('groupEntryPoints', () => { /// last segment labelled every row of a 74-host list `toplevel`. it('drops the trailing segments every row of a set shares', () => { const host = (n: string) => ep(`nixosConfigurations.${n}.config.system.build.toplevel`); - expect(shape([host('broker'), host('caveman')])).toEqual([['NixosConfigurations', ['broker', 'caveman']]]); + expect(shape([host('broker'), host('caveman')])).toEqual([['Nixos Configurations', ['broker', 'caveman']]]); }); it('never strips a label down to nothing', () => { diff --git a/frontend/src/app/features/tasks/task-detail/entry-point-groups.ts b/frontend/src/app/features/tasks/task-detail/entry-point-groups.ts index 3648a1857..4eacf5d02 100644 --- a/frontend/src/app/features/tasks/task-detail/entry-point-groups.ts +++ b/frontend/src/app/features/tasks/task-detail/entry-point-groups.ts @@ -23,8 +23,9 @@ function attrSegments(attr: string): string[] { return segments.length ? segments : [attr]; } -function capitalize(word: string): string { - return word.charAt(0).toUpperCase() + word.slice(1); +function headingOf(set: string): string { + const words = set.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + return words.charAt(0).toUpperCase() + words.slice(1); } function pathWithinSet(entry: EntryPointSummary): { set: string; path: string[] } { @@ -50,7 +51,7 @@ export function groupEntryPoints(entryPoints: EntryPointSummary[]): EntryPointGr return [...sets].map(([set, members]) => { const tail = members.length > 1 ? sharedTailLength(members.map((m) => m.path)) : 0; return { - title: capitalize(set), + title: headingOf(set), rows: members.map(({ entry, path }) => ({ entry, label: path.slice(0, path.length - tail).join('.') })), }; }); diff --git a/frontend/src/app/features/tasks/task-detail/task-detail.component.html b/frontend/src/app/features/tasks/task-detail/task-detail.component.html index bbcc279fe..0250f611f 100644 --- a/frontend/src/app/features/tasks/task-detail/task-detail.component.html +++ b/frontend/src/app/features/tasks/task-detail/task-detail.component.html @@ -79,6 +79,9 @@

{{ proj.display_name }}

{{ triggerLabel(e) }}{{ evalDuration(e) }}
} + @if (hasOlderEvaluations()) { + + }
@@ -157,8 +160,12 @@

{{ evalTitle(sel) }}

} @if (entryPoints().length > 0) { @if (entryPoints().length < entryPointsTotal()) { -
- Loading more ({{ entryPoints().length }} of {{ entryPointsTotal() }}) +
+ + + +
} } @else if (!entryPointsLoading() && isRunning(sel.status)) { diff --git a/frontend/src/app/features/tasks/task-detail/task-detail.component.spec.ts b/frontend/src/app/features/tasks/task-detail/task-detail.component.spec.ts index 509cc2f84..b4aa1c9a5 100644 --- a/frontend/src/app/features/tasks/task-detail/task-detail.component.spec.ts +++ b/frontend/src/app/features/tasks/task-detail/task-detail.component.spec.ts @@ -122,6 +122,7 @@ type SetupOverrides = Partial<{ restartFailedBuilds: () => ReturnType; abortEvaluation: (project: string, proj: string, id: string) => ReturnType; getEntryPoints: () => ReturnType; + getEvaluations: TasksService['getEvaluations']; extraEvals: EvaluationSummary[]; primaryStatus: EvaluationSummary['status']; primary: Partial; @@ -136,6 +137,7 @@ function makeTasksService(access: AccessState, overrides: SetupOverrides = {}): return { getTask: overrides.getTask ?? (() => of(resolvedTask(access, overrides))), getEntryPoints: overrides.getEntryPoints ?? (() => of({ entry_points: [], total: 0 })), + getEvaluations: overrides.getEvaluations ?? (() => of([])), startEvaluation: overrides.startEvaluation ?? (() => of('ok')), restartFailedBuilds: overrides.restartFailedBuilds ?? (() => of('ok')), abortEvaluation: overrides.abortEvaluation ?? (() => of('ok')), @@ -184,6 +186,43 @@ const failedBuilds = { builds: { ...zeroCounts(), failed: 2 } }; const menuLabels = (fixture: ComponentFixture) => fixture.componentInstance.panelMenuModel().map(i => i.label); +describe('TaskDetailComponent - older evaluations', () => { + const access = { managed: false, canEdit: true, canTrigger: true }; + const page = (prefix: string, n: number) => Array.from({ length: n }, (_, i) => evalSummary(`${prefix}${i}`, 'Queued')); + + it('appends the page before the oldest card and stops after a short page', () => { + const getEvaluations = vi.fn() + .mockReturnValueOnce(of(page('a', 10))) + .mockReturnValueOnce(of(page('b', 3))); + const { fixture } = setup(access, { extraEvals: [evalSummary('e2', 'Queued')], getEvaluations }); + const cmp = fixture.componentInstance; + const more = () => (fixture.nativeElement as HTMLElement).querySelector('.strip-more'); + expect(more()).not.toBeNull(); + + cmp.loadOlderEvaluations(); + expect(getEvaluations).toHaveBeenLastCalledWith('acme', 'demo', 10, 'e2'); + cmp.loadOlderEvaluations(); + expect(getEvaluations).toHaveBeenLastCalledWith('acme', 'demo', 10, 'a9'); + cmp.loadOlderEvaluations(); + fixture.detectChanges(); + + expect(getEvaluations).toHaveBeenCalledTimes(2); + expect(cmp.evaluations().map((e) => e.id)).toEqual(['e1', 'e2', ...page('a', 10).map((e) => e.id), 'b0', 'b1', 'b2']); + expect(more()).toBeNull(); + }); + + it('lists an evaluation once when a refresh moves it into the newest page', () => { + const getEvaluations = vi.fn(() => of(page('a', 10))); + const { fixture } = setup(access, { getEvaluations }); + const cmp = fixture.componentInstance; + cmp.loadOlderEvaluations(); + cmp.task.set({ ...cmp.task()!, last_evaluations: [evalSummary('new'), evalSummary('e1'), evalSummary('a0', 'Queued')] }); + + const ids = cmp.evaluations().map((e) => e.id); + expect(ids).toEqual(['new', 'e1', 'a0', ...page('a', 10).slice(1).map((e) => e.id)]); + }); +}); + describe('TaskDetailComponent - loading', () => { const access = { managed: false, canEdit: true, canTrigger: true }; @@ -874,7 +913,8 @@ describe('TaskDetailComponent - #636 eval page', () => { fixture.detectChanges(); const spy = vi.spyOn(tasksService, 'getEntryPoints').mockReturnValue(of({ entry_points: [epSummary('b')], total: 2 })); const sentinel = fixture.debugElement.query(By.css('.pkg-more')); - expect(fixture.nativeElement.textContent).not.toContain('Show more'); + expect(sentinel.nativeElement.querySelectorAll('gr-skeleton').length).toBeGreaterThan(0); + expect(sentinel.nativeElement.textContent.trim()).toBe(''); sentinel.triggerEventHandler('grInView'); expect(spy).toHaveBeenCalledWith('acme', 'demo', 'e1', 25, 1); fixture.detectChanges(); diff --git a/frontend/src/app/features/tasks/task-detail/task-detail.component.ts b/frontend/src/app/features/tasks/task-detail/task-detail.component.ts index 73efdc8f7..e849aa321 100644 --- a/frontend/src/app/features/tasks/task-detail/task-detail.component.ts +++ b/frontend/src/app/features/tasks/task-detail/task-detail.component.ts @@ -8,7 +8,7 @@ import { Component, OnInit, OnDestroy, ElementRef, HostListener, computed, injec import { CommonModule, DOCUMENT } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { interval, Observable, Subscription } from 'rxjs'; +import { finalize, interval, Observable, Subscription } from 'rxjs'; import { auditTime, filter, share } from 'rxjs/operators'; import { LiveEvent, LiveService } from '@core/services/live.service'; import { AuthService } from '@core/services/auth.service'; @@ -108,7 +108,15 @@ export class TaskDetailComponent implements OnInit, OnDestroy { private lastEntryPointsFetch = 0; private readonly ENTRY_POINTS_LIVE_INTERVAL_MS = 4000; - evaluations = computed(() => this.task()?.last_evaluations ?? []); + private readonly OLDER_PAGE = 10; + private olderEvaluations = signal([]); + private loadingOlder = false; + hasOlderEvaluations = signal(true); + evaluations = computed(() => { + const newest = this.task()?.last_evaluations ?? []; + const shown = new Set(newest.map(e => e.id)); + return [...newest, ...this.olderEvaluations().filter(e => !shown.has(e.id))]; + }); selected = computed(() => { const id = this.selectedId(); const list = this.evaluations(); @@ -168,6 +176,21 @@ export class TaskDetailComponent implements OnInit, OnDestroy { else this.selectedId.set(id); } + loadOlderEvaluations(): void { + const oldest = this.evaluations().at(-1); + if (this.loadingOlder || !this.hasOlderEvaluations() || !oldest) return; + this.loadingOlder = true; + this.tasksService.getEvaluations(this.projectName, this.taskName, this.OLDER_PAGE, oldest.id) + .pipe(finalize(() => (this.loadingOlder = false))) + .subscribe({ + next: page => { + this.olderEvaluations.update(loaded => [...loaded, ...page]); + if (page.length < this.OLDER_PAGE) this.hasOlderEvaluations.set(false); + }, + error: () => this.hasOlderEvaluations.set(false), + }); + } + loadTaskData(live = false): void { this.tasksService.getTask(this.projectName, this.taskName).subscribe({ next: (task) => this.applyTask(task, live), diff --git a/frontend/src/app/features/tasks/task-detail/task-detail.evaluations.scss b/frontend/src/app/features/tasks/task-detail/task-detail.evaluations.scss index 21476aac5..bb59105be 100644 --- a/frontend/src/app/features/tasks/task-detail/task-detail.evaluations.scss +++ b/frontend/src/app/features/tasks/task-detail/task-detail.evaluations.scss @@ -84,6 +84,7 @@ h1 { font-size: $font-size-xxl; margin: 0; } } .eval-strip { display: flex; gap: $spacing-sm; align-items: flex-start; } +.strip-more { flex: none; width: 1px; align-self: stretch; } .eval-card { border: 1px solid var(--gr-border-subtle); diff --git a/frontend/src/app/features/tasks/task-detail/task-detail.packages.scss b/frontend/src/app/features/tasks/task-detail/task-detail.packages.scss index d729142d5..b472829aa 100644 --- a/frontend/src/app/features/tasks/task-detail/task-detail.packages.scss +++ b/frontend/src/app/features/tasks/task-detail/task-detail.packages.scss @@ -11,10 +11,6 @@ .pkg-more { margin-top: $space-pair; - padding: $spacing-sm $spacing-md; - color: var(--gr-text-muted); - font-size: $font-size-sm; - text-align: center; } .pkg { diff --git a/frontend/src/app/shared/text/device-name.spec.ts b/frontend/src/app/shared/text/device-name.spec.ts new file mode 100644 index 000000000..250a1ab10 --- /dev/null +++ b/frontend/src/app/shared/text/device-name.spec.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { deviceName } from './device-name'; + +describe('deviceName', () => { + it.each([ + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36', 'Chrome 153 on Linux'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', 'Edge 140 on Windows'], + ['Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:131.0) Gecko/20100101 Firefox/131.0', 'Firefox 131 on macOS'], + ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', 'Safari 18 on iOS'], + ['Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Mobile Safari/537.36', 'Chrome 139 on Android'], + ])('names %s', (ua, name) => { + expect(deviceName(ua)).toBe(name); + }); + + it('keeps the product of a client it does not recognise', () => { + expect(deviceName('gradient-cli/2.0.0')).toBe('gradient-cli/2.0.0'); + }); + + it('falls back when the device sent nothing', () => { + expect(deviceName(null)).toBe('Unknown device'); + }); +}); diff --git a/frontend/src/app/shared/text/device-name.ts b/frontend/src/app/shared/text/device-name.ts new file mode 100644 index 000000000..fce4ef668 --- /dev/null +++ b/frontend/src/app/shared/text/device-name.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const BROWSERS: [RegExp, string][] = [ + [/Edg(?:e|A|iOS)?\/(\d+)/, 'Edge'], + [/OPR\/(\d+)/, 'Opera'], + [/(?:Firefox|FxiOS)\/(\d+)/, 'Firefox'], + [/(?:Chrome|CriOS)\/(\d+)/, 'Chrome'], + [/Version\/(\d+).*Safari\//, 'Safari'], +]; + +// iOS reports itself "like Mac OS X" and Android runs on Linux, so the more specific system comes first. +const SYSTEMS: [RegExp, string][] = [ + [/Windows/, 'Windows'], + [/iPhone|iPad|iPod/, 'iOS'], + [/Mac OS X|Macintosh/, 'macOS'], + [/Android/, 'Android'], + [/CrOS/, 'ChromeOS'], + [/Linux/, 'Linux'], +]; + +export function deviceName(userAgent: string | null): string { + if (!userAgent) return 'Unknown device'; + const browser = BROWSERS.find(([pattern]) => pattern.test(userAgent)); + if (!browser) return userAgent.split(' ')[0]; + const [pattern, name] = browser; + const version = userAgent.match(pattern)?.[1]; + const system = SYSTEMS.find(([p]) => p.test(userAgent))?.[1]; + return `${name} ${version}` + (system ? ` on ${system}` : ''); +} diff --git a/frontend/src/app/shared/text/index.ts b/frontend/src/app/shared/text/index.ts index 09a17575a..358a40d74 100644 --- a/frontend/src/app/shared/text/index.ts +++ b/frontend/src/app/shared/text/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +export * from './device-name'; export * from './permission-label'; export * from './relative-time'; export * from './slug'; diff --git a/frontend/src/app/shared/ui/index.ts b/frontend/src/app/shared/ui/index.ts index cb1b2e29d..9da1d8f0e 100644 --- a/frontend/src/app/shared/ui/index.ts +++ b/frontend/src/app/shared/ui/index.ts @@ -17,3 +17,4 @@ export * from './segmented-bar/byte-segments'; export * from './segmented-bar/segmented-bar.component'; export * from './star-button/star-button.component'; export * from './status-icon/status-icon.component'; +export * from './permission-picker/permission-picker.component'; diff --git a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.scss b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.scss similarity index 81% rename from frontend/src/app/features/caches/members-roles/cache-members-roles.component.scss rename to frontend/src/app/shared/ui/permission-picker/permission-picker.component.scss index 0f2803340..36050e2ca 100644 --- a/frontend/src/app/features/caches/members-roles/cache-members-roles.component.scss +++ b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.scss @@ -4,7 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -@use '@gradient/ui/styles/variables' as *;// One permission per line: the names are long enough that two columns collide. +@use '@gradient/ui/styles/variables' as *; + .permissions-list { display: flex; flex-direction: column; diff --git a/frontend/src/app/shared/ui/permission-picker/permission-picker.component.spec.ts b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.spec.ts new file mode 100644 index 000000000..4b49c3142 --- /dev/null +++ b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.spec.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { PermissionPickerComponent } from './permission-picker.component'; + +@Component({ + standalone: true, + imports: [PermissionPickerComponent], + template: ``, +}) +class Host { + permissions = [ + { id: 'viewProject', mutating: false }, + { id: 'deleteProject', mutating: true }, + ]; + selection = signal>({ viewProject: true }); +} + +describe('PermissionPickerComponent', () => { + function render() { + const fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + return { fixture, root: fixture.nativeElement as HTMLElement }; + } + + it('labels each permission and marks whether it changes anything', () => { + const { root } = render(); + const rows = Array.from(root.querySelectorAll('.permission-row')).map((r) => r.textContent?.replace(/\s+/g, ' ').trim()); + expect(rows).toEqual(['view project read-only', 'delete project mutating']); + }); + + it('hands back a new selection when a permission is ticked', async () => { + const { fixture, root } = render(); + const before = fixture.componentInstance.selection(); + (root.querySelector('#perm-deleteProject') as HTMLInputElement).click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.componentInstance.selection()).toEqual({ viewProject: true, deleteProject: true }); + expect(before).toEqual({ viewProject: true }); + }); +}); diff --git a/frontend/src/app/shared/ui/permission-picker/permission-picker.component.ts b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.ts new file mode 100644 index 000000000..7151f6bc1 --- /dev/null +++ b/frontend/src/app/shared/ui/permission-picker/permission-picker.component.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { BadgeComponent, CheckboxComponent } from '@gradient/ui/ui'; +import { permissionLabel } from '@shared/text'; + +export interface PickablePermission { + id: string; + mutating: boolean; +} + +@Component({ + selector: 'app-permission-picker', + standalone: true, + imports: [FormsModule, BadgeComponent, CheckboxComponent], + template: ` +
+ @for (perm of permissions(); track perm.id) { +
+ + + {{ perm.mutating ? 'mutating' : 'read-only' }} + +
+ } +
+ `, + changeDetection: ChangeDetectionStrategy.Eager, + styleUrl: './permission-picker.component.scss', +}) +export class PermissionPickerComponent { + permissions = input.required(); + selection = model>({}); + + protected readonly label = permissionLabel; + + protected set(id: string, on: boolean): void { + this.selection.set({ ...this.selection(), [id]: on }); + } +}