diff --git a/grr/client_builder/setup.py b/grr/client_builder/setup.py index 8202d83a4..37837b3a0 100644 --- a/grr/client_builder/setup.py +++ b/grr/client_builder/setup.py @@ -71,7 +71,7 @@ def make_release_tree(self, base_dir, files): "grr-response-client==%s" % VERSION.get("Version", "packagedepends"), "grr-response-core==%s" % VERSION.get("Version", "packagedepends"), "PyInstaller==3.6", - "fleetspeak-client-bin==0.1.7.4", + "fleetspeak-client-bin==0.1.9", "olefile==0.46", ], diff --git a/grr/core/grr_response_core/lib/rdfvalues/artifacts.py b/grr/core/grr_response_core/lib/rdfvalues/artifacts.py index f81c6b090..a3142ce99 100644 --- a/grr/core/grr_response_core/lib/rdfvalues/artifacts.py +++ b/grr/core/grr_response_core/lib/rdfvalues/artifacts.py @@ -413,6 +413,18 @@ def Validate(self): raise ValueError("No artifacts to collect.") +class ArtifactProgress(rdf_structs.RDFProtoStruct): + """Collection progress of an Artifact.""" + protobuf = flows_pb2.ArtifactProgress + rdf_deps = [] + + +class ArtifactCollectorFlowProgress(rdf_structs.RDFProtoStruct): + """Collection progress of ArtifactCollectorFlow.""" + protobuf = flows_pb2.ArtifactCollectorFlowProgress + rdf_deps = [ArtifactProgress] + + class ClientArtifactCollectorArgs(rdf_structs.RDFProtoStruct): """An RDFValue representation of an artifact bundle.""" protobuf = artifact_pb2.ClientArtifactCollectorArgs diff --git a/grr/core/setup.py b/grr/core/setup.py index 24e1e0b78..c62609535 100644 --- a/grr/core/setup.py +++ b/grr/core/setup.py @@ -152,7 +152,7 @@ def make_release_tree(self, base_dir, files): "python-dateutil==2.8.1", "pytsk3==20200117", "pytz==2020.1", - "PyYAML==5.3.1", + "PyYAML==5.4.1", "requests==2.25.1", "yara-python==4.0.1", ], diff --git a/grr/proto/grr_response_proto/flows.proto b/grr/proto/grr_response_proto/flows.proto index 0a52cf176..4bf3cd10c 100644 --- a/grr/proto/grr_response_proto/flows.proto +++ b/grr/proto/grr_response_proto/flows.proto @@ -723,6 +723,15 @@ message ArtifactCollectorFlowArgs { }]; } +message ArtifactProgress { + optional string name = 1; + optional uint32 num_results = 2; +} + +message ArtifactCollectorFlowProgress { + repeated ArtifactProgress artifacts = 1; +} + // Next field ID: 11 message ArtifactFilesDownloaderFlowArgs { repeated string artifact_list = 1 [(sem_type) = { diff --git a/grr/server/grr_response_server/flows/general/collectors.py b/grr/server/grr_response_server/flows/general/collectors.py index d05f9e6e3..a8c3074bd 100644 --- a/grr/server/grr_response_server/flows/general/collectors.py +++ b/grr/server/grr_response_server/flows/general/collectors.py @@ -88,6 +88,7 @@ class ArtifactCollectorFlow(flow_base.FlowBase): category = "/Collectors/" args_type = rdf_artifacts.ArtifactCollectorFlowArgs + progress_type = rdf_artifacts.ArtifactCollectorFlowProgress behaviours = flow_base.BEHAVIOUR_BASIC def Start(self): @@ -98,6 +99,7 @@ def Start(self): self.state.failed_count = 0 self.state.knowledge_base = self.args.knowledge_base self.state.response_count = 0 + self.state.progress = rdf_artifacts.ArtifactCollectorFlowProgress() if self.args.use_tsk and self.args.use_raw_filesystem_access: raise ValueError( @@ -162,6 +164,9 @@ def Collect(self, artifact_obj): """Collect the raw data from the client for this artifact.""" artifact_name = artifact_obj.name + # Ensure attempted artifacts are shown in progress, even with 0 results. + self._GetOrInsertArtifactProgress(artifact_name) + test_conditions = list(artifact_obj.conditions) os_conditions = ConvertSupportedOSToConditions(artifact_obj) if os_conditions: @@ -748,6 +753,10 @@ def _ParseResponses(self, responses, artifact_name, source): else: results = responses + # Increment artifact result count in flow progress. + progress = self._GetOrInsertArtifactProgress(artifact_name) + progress.num_results += len(results) + for result in results: result_type = result.__class__.__name__ if result_type == "Anomaly": @@ -756,6 +765,18 @@ def _ParseResponses(self, responses, artifact_name, source): self.state.response_count += 1 self.SendReply(result, tag="artifact:%s" % artifact_name) + def GetProgress(self) -> rdf_artifacts.ArtifactCollectorFlowProgress: + return self.state.progress + + def _GetOrInsertArtifactProgress(self, + name: str) -> rdf_artifacts.ArtifactProgress: + try: + return next(a for a in self.state.progress.artifacts if a.name == name) + except StopIteration: + progress = rdf_artifacts.ArtifactProgress(name=name) + self.state.progress.artifacts.append(progress) + return progress + def End(self, responses): del responses # If we got no responses, and user asked for it, we error out. diff --git a/grr/server/grr_response_server/flows/general/collectors_test.py b/grr/server/grr_response_server/flows/general/collectors_test.py index 6dfc83c09..ccb314956 100644 --- a/grr/server/grr_response_server/flows/general/collectors_test.py +++ b/grr/server/grr_response_server/flows/general/collectors_test.py @@ -452,6 +452,56 @@ def testParsingFailure(self): results = flow_test_lib.GetFlowResults(client_id, flow_id) self.assertEmpty(results) + def testFlowProgressHasEntryForArtifactWithoutResults(self): + + client_id = self.SetupClient(0, system="Linux") + with utils.Stubber(psutil, "process_iter", lambda: iter([])): + client_mock = action_mocks.ActionMock(standard.ListProcesses) + + self.fakeartifact.sources.append( + rdf_artifacts.ArtifactSource( + type=rdf_artifacts.ArtifactSource.SourceType.GRR_CLIENT_ACTION, + attributes={"client_action": standard.ListProcesses.__name__})) + + flow_id = flow_test_lib.TestFlowHelper( + collectors.ArtifactCollectorFlow.__name__, + client_mock, + artifact_list=["FakeArtifact"], + client_id=client_id) + + progress = flow_test_lib.GetFlowProgress(client_id, flow_id) + self.assertLen(progress.artifacts, 1) + self.assertEqual(progress.artifacts[0].name, "FakeArtifact") + self.assertEqual(progress.artifacts[0].num_results, 0) + + def testFlowProgressIsCountingResults(self): + + def _Iter(): + return iter([ + client_test_lib.MockWindowsProcess(), + client_test_lib.MockWindowsProcess() + ]) + + client_id = self.SetupClient(0, system="Linux") + with utils.Stubber(psutil, "process_iter", _Iter): + client_mock = action_mocks.ActionMock(standard.ListProcesses) + + self.fakeartifact.sources.append( + rdf_artifacts.ArtifactSource( + type=rdf_artifacts.ArtifactSource.SourceType.GRR_CLIENT_ACTION, + attributes={"client_action": standard.ListProcesses.__name__})) + + flow_id = flow_test_lib.TestFlowHelper( + collectors.ArtifactCollectorFlow.__name__, + client_mock, + artifact_list=["FakeArtifact"], + client_id=client_id) + + progress = flow_test_lib.GetFlowProgress(client_id, flow_id) + self.assertLen(progress.artifacts, 1) + self.assertEqual(progress.artifacts[0].name, "FakeArtifact") + self.assertEqual(progress.artifacts[0].num_results, 2) + class RelationalTestArtifactCollectors(ArtifactCollectorsTestMixin, test_lib.GRRBaseTest): diff --git a/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ng.html b/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ng.html index 87a1806d5..20e2024be 100644 --- a/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ng.html +++ b/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ng.html @@ -25,7 +25,8 @@ -
+
diff --git a/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ts b/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ts index f41d24c6e..cf2331bbf 100644 --- a/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ts +++ b/grr/server/grr_response_server/gui/ui/components/client_page/client_page.ts @@ -53,12 +53,15 @@ export class ClientPage implements OnInit, AfterViewInit, OnDestroy { @ViewChild('clientDetailsDrawer') clientDetailsDrawer!: MatDrawer; - @ViewChild(Approval, {read: ElementRef}) approvalViewContainer!: ElementRef; + @ViewChild(Approval, {read: ElementRef}) approvalViewContainer?: ElementRef; approvalHeight: number = 0; + readonly showApprovalView$ = this.clientPageFacade.approvalsEnabled$; + private readonly resizeObserver = new ResizeObserver(() => { - this.approvalHeight = this.approvalViewContainer.nativeElement.offsetHeight; + this.approvalHeight = + this.approvalViewContainer?.nativeElement.offsetHeight ?? 0; this.changeDetectorRef.markForCheck(); }); @@ -88,7 +91,9 @@ export class ClientPage implements OnInit, AfterViewInit, OnDestroy { } ngAfterViewInit() { - this.resizeObserver.observe(this.approvalViewContainer.nativeElement); + if (this.approvalViewContainer !== undefined) { + this.resizeObserver.observe(this.approvalViewContainer.nativeElement); + } const urlTokens = this.router.routerState.snapshot.url.split('/'); if (urlTokens[urlTokens.length - 1] === ClientPage.CLIENT_DETAILS_ROUTE) { diff --git a/grr/server/grr_response_server/gui/ui/components/client_page/client_page_test.ts b/grr/server/grr_response_server/gui/ui/components/client_page/client_page_test.ts index 8de3af26e..2e5a1afe8 100644 --- a/grr/server/grr_response_server/gui/ui/components/client_page/client_page_test.ts +++ b/grr/server/grr_response_server/gui/ui/components/client_page/client_page_test.ts @@ -161,4 +161,28 @@ describe('ClientPage Component', () => { tick(); discardPeriodicTasks(); })); + + it('shows approval iff approvalsEnabled$', () => { + const fixture = TestBed.createComponent(ClientComponent); + fixture.detectChanges(); + + clientPageFacade.approvalsEnabledSubject.next(true); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.client-approval')) + .styles['display']) + .toEqual('block'); + }); + + it('does not show approval if approvalsEnabled$ is false', () => { + const fixture = TestBed.createComponent(ClientComponent); + fixture.detectChanges(); + + clientPageFacade.approvalsEnabledSubject.next(false); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.client-approval')) + .styles['display']) + .toEqual('none'); + }); }); diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/module.ts b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/module.ts index c5d5d9710..85b5c0db2 100644 --- a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/module.ts +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/module.ts @@ -13,6 +13,7 @@ import {TimestampModule} from '@app/components/timestamp/module'; import {FileResultsTable} from './file_results_table'; import {OsqueryResultsTable} from './osquery_results_table'; +import {ResultAccordion} from './result_accordion'; /** @@ -37,11 +38,13 @@ import {OsqueryResultsTable} from './osquery_results_table'; FileResultsTable, FileModePipe, OsqueryResultsTable, + ResultAccordion, ], exports: [ FileResultsTable, FileModePipe, OsqueryResultsTable, + ResultAccordion, ], }) export class HelpersModule { diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ng.html b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ng.html new file mode 100644 index 000000000..ba8cb1a6f --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ng.html @@ -0,0 +1,23 @@ +
+
+
+
+ {{ title }} +
+ +
+ + {{ isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right' }} + +
+
+ +
+ + + +
+
+
diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.scss b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.scss new file mode 100644 index 000000000..0bab358eb --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.scss @@ -0,0 +1,100 @@ +@use '~material-theme' as c; + +$header-left-padding: 24px; +$files-counter-offset: 135px; + +.row-list { + margin-bottom: 0.15em; + + .row { + .header { + cursor: pointer; + + position: relative; + bottom: -2px; + &:hover { + background-color: c.mat-color(c.$primary, 100, 0.15); + } + + border-top: 1px solid c.mat-color(c.$foreground, divider-light); + padding: 0.5em 0 0.5em $header-left-padding; + + display: flex; + align-items: center; + + .row-text { + position: relative; + right: $files-counter-offset; + white-space: nowrap; + } + + .title, + .in-progress, + .success, + .warning, + .error { + display: flex; + align-items: center; + + .material-icons { + position: relative; + top: -1px; + } + } + + .title { + flex-grow: 1; + font-family: c.$google-sans-display-family; + font-weight: 400; + + .arrow-icon { + font-size: 36px; + } + } + + .success { + color: c.mat-color(c.$foreground, text-light); + font-size: 90%; + + .material-icons { + color: c.mat-color(c.$foreground, success); + margin-left: 0.5em; + } + } + + .warning { + color: c.mat-color(c.$warn, darker); + font-size: 90%; + + .material-icons { + color: c.mat-color(c.$warn); + margin-left: 0.5em; + } + } + + .error { + color: c.mat-color(c.$danger); + font-size: 90%; + + .material-icons { + margin-left: 0.5em; + } + } + + .expansion-indicator { + color: c.mat-color(c.$foreground, divider); + font-size: 30px; + text-align: center; + width: 40px; + } + } + + .results-in-progress { + padding: 0.5em 24px; + } + } +} + +.expand-results { + width: 100%; +} diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ts b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ts new file mode 100644 index 000000000..17a473681 --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion.ts @@ -0,0 +1,37 @@ +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; + +/** Component that displays an expendable flow result row. */ +@Component({ + selector: 'result-accordion', + templateUrl: './result_accordion.ng.html', + styleUrls: ['./result_accordion.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResultAccordion { + @Input() title?: string; + + @Input() hasMoreResults: boolean = true; + + isOpen: boolean = false; + + @Output() readonly loadMore = new EventEmitter(); + + private firstOpen = true; + + get hasResults() { + return this.hasMoreResults || !this.firstOpen; + } + + toggle() { + if (this.firstOpen) { + this.firstOpen = false; + this.loadMore.emit(); + } + + this.isOpen = !this.isOpen; + } + + loadMoreClicked() { + this.loadMore.emit(); + } +} diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion_test.ts b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion_test.ts new file mode 100644 index 000000000..0f721e6db --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/helpers/result_accordion_test.ts @@ -0,0 +1,80 @@ +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {initTestEnvironment} from '@app/testing'; + +import {HelpersModule} from './module'; + + + + +initTestEnvironment(); + + +@Component({ + template: ` + + contenttext +` +}) +class TestHostComponent { + title?: string; + hasMoreResults: boolean = true; + loadMoreTriggered = jasmine.createSpy('loadMoreTriggered'); +} + +describe('ResultAccordion Component', () => { + beforeEach(waitForAsync(() => { + TestBed + .configureTestingModule({ + imports: [ + NoopAnimationsModule, + HelpersModule, + ], + declarations: [ + TestHostComponent, + ], + + providers: [] + }) + .compileComponents(); + })); + + function createComponent(args: Partial = {}): + ComponentFixture { + const fixture = TestBed.createComponent(TestHostComponent); + fixture.componentInstance.title = args.title; + fixture.componentInstance.hasMoreResults = args.hasMoreResults ?? true; + fixture.detectChanges(); + + return fixture; + } + + it('shows title', () => { + const fixture = createComponent({title: 'foobar'}); + expect(fixture.debugElement.nativeElement.textContent).toContain('foobar'); + }); + + it('shows content on click', () => { + const fixture = createComponent({hasMoreResults: true}); + + expect(fixture.debugElement.nativeElement.textContent) + .not.toContain('contenttext'); + fixture.debugElement.query(By.css('.header')).nativeElement.click(); + fixture.detectChanges(); + expect(fixture.debugElement.nativeElement.textContent) + .toContain('contenttext'); + }); + + it('emits loadMore on first open', () => { + const fixture = createComponent({hasMoreResults: true}); + expect(fixture.componentInstance.loadMoreTriggered).not.toHaveBeenCalled(); + + fixture.debugElement.query(By.css('.header')).nativeElement.click(); + expect(fixture.componentInstance.loadMoreTriggered).toHaveBeenCalled(); + }); +}); diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ng.html b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ng.html index e4077b1f8..3c8dda24b 100644 --- a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ng.html +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ng.html @@ -9,7 +9,7 @@
-
+ @@ -27,8 +27,6 @@ View {{ totalUnknownResults | i18nPlural: {'=1': '1 parsed result', 'other': '# parsed results'} }} in old UI + + - -
diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ts b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ts index 0bd5e45da..eba6c76cc 100644 --- a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ts +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_details.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; import {flowFileResultFromStatEntry} from '@app/components/flow_details/helpers/file_results_table'; -import {ExecuteResponse, StatEntry} from '@app/lib/api/api_interfaces'; +import {ArtifactCollectorFlowArgs, ExecuteResponse, StatEntry} from '@app/lib/api/api_interfaces'; import {HttpApiService} from '@app/lib/api/http_api_service'; import {FlowListEntry, FlowState} from '@app/lib/models/flow'; import {combineLatest, Observable, ReplaySubject} from 'rxjs'; @@ -81,9 +81,11 @@ export class ArtifactCollectorFlowDetails extends Plugin implements OnInit { ); readonly hasMoreResults$ = - combineLatest([ - this.totalResultsRequested$, this.totalResults$ - ]).pipe(map(([requested, loaded]) => requested <= loaded)); + combineLatest([this.totalResultsRequested$, this.totalResults$]) + .pipe( + map(([requested, loaded]) => requested <= loaded), + startWith(true), + ); readonly totalUnknownResults$ = combineLatest([ @@ -92,6 +94,10 @@ export class ArtifactCollectorFlowDetails extends Plugin implements OnInit { this.totalExecuteResponseResults$, ]).pipe(map(([total, file, execute]) => total - file - execute)); + readonly flowArgs$: Observable = + this.flowListEntry$.pipe( + map(fle => fle.flow.args as ArtifactCollectorFlowArgs)); + constructor(private readonly httpApiService: HttpApiService) { super(); } diff --git a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_test.ts b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_test.ts index 726cbc4d4..17caf1bde 100644 --- a/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_test.ts +++ b/grr/server/grr_response_server/gui/ui/components/flow_details/plugins/artifact_collector_flow_test.ts @@ -1,4 +1,4 @@ -import {TestBed, waitForAsync} from '@angular/core/testing'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {FlowState} from '@app/lib/models/flow'; @@ -7,6 +7,7 @@ import {initTestEnvironment} from '@app/testing'; import {firstValueFrom} from 'rxjs'; import {ExecuteResponse} from '../../../lib/api/api_interfaces'; +import {ResultAccordion} from '../helpers/result_accordion'; import {ArtifactCollectorFlowDetails} from './artifact_collector_flow_details'; import {PluginsModule} from './module'; @@ -15,6 +16,13 @@ import {PluginsModule} from './module'; initTestEnvironment(); +function openResultAccordion(fixture: ComponentFixture<{}>) { + const accordion = fixture.debugElement.query(By.directive(ResultAccordion)); + expect(accordion.nativeElement).not.toBeNull(); + accordion.componentInstance.toggle(); + fixture.detectChanges(); +} + describe('artifact-collector-flow-details component', () => { beforeEach(waitForAsync(() => { TestBed @@ -34,7 +42,7 @@ describe('artifact-collector-flow-details component', () => { fixture.componentInstance.flowListEntry = { flow: newFlow({ state: FlowState.FINISHED, - args: {}, + args: {artifactList: ['foobar']}, }), resultSets: [ newFlowResultSet({stSize: 123, pathspec: {path: '/foo'}}, 'StatEntry'), @@ -42,6 +50,8 @@ describe('artifact-collector-flow-details component', () => { }; fixture.detectChanges(); + openResultAccordion(fixture); + expect(fixture.nativeElement.innerText).toContain('/foo'); expect(fixture.nativeElement.innerText).toContain('123'); }); @@ -61,7 +71,7 @@ describe('artifact-collector-flow-details component', () => { fixture.componentInstance.flowListEntry = { flow: newFlow({ state: FlowState.FINISHED, - args: {}, + args: {artifactList: ['foobar']}, }), resultSets: [ newFlowResultSet(response, 'ExecuteResponse'), @@ -69,6 +79,8 @@ describe('artifact-collector-flow-details component', () => { }; fixture.detectChanges(); + openResultAccordion(fixture); + expect(fixture.nativeElement.innerText).toContain('/bin/foo'); expect(fixture.nativeElement.innerText).toContain('bar'); expect(fixture.nativeElement.innerText).toContain('err123'); @@ -80,7 +92,7 @@ describe('artifact-collector-flow-details component', () => { fixture.componentInstance.flowListEntry = { flow: newFlow({ state: FlowState.FINISHED, - args: {}, + args: {artifactList: ['foobar']}, }), resultSets: [], }; @@ -89,11 +101,7 @@ describe('artifact-collector-flow-details component', () => { const flowResultsQueryPromise = firstValueFrom(fixture.componentInstance.flowResultsQuery); - const button = fixture.debugElement.query( - By.css('button[aria-label="Load more results"]')); - expect(button.nativeElement).not.toBeNull(); - button.nativeElement.click(); - fixture.detectChanges(); + openResultAccordion(fixture); const flowResultsQuery = await flowResultsQueryPromise; expect(flowResultsQuery.offset).toEqual(0); @@ -106,7 +114,7 @@ describe('artifact-collector-flow-details component', () => { fixture.componentInstance.flowListEntry = { flow: newFlow({ state: FlowState.FINISHED, - args: {}, + args: {artifactList: ['foobar']}, }), resultSets: [ newFlowResultSet({}, 'Unknown'), @@ -114,6 +122,8 @@ describe('artifact-collector-flow-details component', () => { }; fixture.detectChanges(); + openResultAccordion(fixture); + expect(fixture.nativeElement.innerText).toContain('old UI'); }); }); diff --git a/grr/server/grr_response_server/gui/ui/lib/polling.ts b/grr/server/grr_response_server/gui/ui/lib/polling.ts new file mode 100644 index 000000000..aacbbb303 --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/lib/polling.ts @@ -0,0 +1,61 @@ +import {Observable, timer} from 'rxjs'; + + +interface PollArgs { + readonly pollIntervalMs: number; + readonly pollEffect: () => void; + readonly selector: Observable; + readonly pollWhile?: (value: T) => boolean; +} + +/** + * Polls a side-effect and passes through emitted values. + * + * This function returns an Observable that, while being subscribed to: + * + * 1) Calls pollEffect() every `pollIntervalMs`, starting immediately upon + * subscription. + * 2) Passes through `selector`. + * + * Both the polling and the subscription to selector are stopped, as soon as: + * * `selector` completes, or + * * the caller unsubscribes from poll(), or + * * pollWhile() returns false. + * + * This function is designed to interoperate with RxJS: + * * pollEffect() can trigger an RxJS effect that calls an API and updates + * the store. + * * selector is a selector that reads from the updated store field. + */ +export function poll(args: PollArgs) { + return new Observable(subscriber => { + const timerSub = timer(0, args.pollIntervalMs).subscribe(() => { + args.pollEffect(); + }); + + const selectorSub = args.selector.subscribe({ + next(v) { + subscriber.next(v); + + if (args.pollWhile !== undefined && !args.pollWhile(v)) { + unsubscribe(); + subscriber.complete(); + } + }, + error(e) { + subscriber.error(e); + }, + complete() { + timerSub.unsubscribe(); + subscriber.complete(); + }, + }); + + const unsubscribe = () => { + timerSub.unsubscribe(); + selectorSub.unsubscribe(); + }; + + return unsubscribe; + }); +} diff --git a/grr/server/grr_response_server/gui/ui/lib/polling_test.ts b/grr/server/grr_response_server/gui/ui/lib/polling_test.ts new file mode 100644 index 000000000..04c56d7cd --- /dev/null +++ b/grr/server/grr_response_server/gui/ui/lib/polling_test.ts @@ -0,0 +1,164 @@ +import {fakeAsync, tick} from '@angular/core/testing'; +import {initTestEnvironment} from '@app/testing'; +import {firstValueFrom, lastValueFrom, Subject} from 'rxjs'; + +import {poll} from './polling'; + + +initTestEnvironment(); + +describe('poll', () => { + it('polls the effect on subscription ', fakeAsync(() => { + const pollEffect = jasmine.createSpy('pollEffect'); + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect, + selector: new Subject(), + }); + + expect(pollEffect).not.toHaveBeenCalled(); + + const subscription = poll$.subscribe(); + + tick(0); + + expect(pollEffect).toHaveBeenCalledTimes(1); + + tick(9); + + expect(pollEffect).toHaveBeenCalledTimes(1); + + tick(1); + + expect(pollEffect).toHaveBeenCalledTimes(2); + + tick(9); + + expect(pollEffect).toHaveBeenCalledTimes(2); + + tick(1); + + expect(pollEffect).toHaveBeenCalledTimes(3); + + subscription.unsubscribe(); + })); + + it('emits value from selector', fakeAsync(async () => { + const selector = new Subject(); + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect: () => { + selector.next('foobar'); + }, + selector, + }); + + const promise = firstValueFrom(poll$); + + tick(0); + + expect(await promise).toEqual('foobar'); + })); + + it('emits latest value from selector', fakeAsync(() => { + const selector = new Subject(); + let counter = 0; + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect: () => { + selector.next(counter); + counter += 1; + }, + selector, + }); + + const emittedValues: number[] = []; + const subscription = poll$.subscribe(v => { + emittedValues.push(v); + }); + + tick(0); + + expect(emittedValues).toEqual([0]); + + tick(10); + + expect(emittedValues).toEqual([0, 1]); + + tick(10); + + expect(emittedValues).toEqual([0, 1, 2]); + + subscription.unsubscribe(); + })); + + + it('stops polling after unsubscribe', fakeAsync(async () => { + const selector = new Subject(); + const pollEffect = jasmine.createSpy('pollEffect').and.callFake(() => { + selector.next('foobar'); + }); + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect, + selector, + }); + + const promise = firstValueFrom(poll$); + + tick(0); + + expect(await promise).toEqual('foobar'); + + tick(20); + + expect(pollEffect).toHaveBeenCalledTimes(1); + })); + + it('continues polling while pollWhile() returns true', fakeAsync(() => { + const pollEffect = jasmine.createSpy('pollEffect'); + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect, + selector: new Subject(), + pollWhile: () => true, + }); + + const subscription = poll$.subscribe(); + + tick(20); + + expect(pollEffect).toHaveBeenCalledTimes(3); + + subscription.unsubscribe(); + })); + + it('stops polling when pollWhile is false', fakeAsync(async () => { + let counter = 0; + const selector = new Subject(); + const pollEffect = jasmine.createSpy('pollEffect').and.callFake(() => { + selector.next(counter); + counter += 1; + }); + + const poll$ = poll({ + pollIntervalMs: 10, + pollEffect, + selector, + pollWhile: (value) => value < 1, + }); + + const promise = lastValueFrom(poll$); + + tick(30); + + expect(await promise).toEqual(1); + + expect(pollEffect).toHaveBeenCalledTimes(2); + })); +}); diff --git a/grr/server/grr_response_server/gui/ui/store/approval_page_facade.ts b/grr/server/grr_response_server/gui/ui/store/approval_page_facade.ts index 41c609134..bb89808b4 100644 --- a/grr/server/grr_response_server/gui/ui/store/approval_page_facade.ts +++ b/grr/server/grr_response_server/gui/ui/store/approval_page_facade.ts @@ -3,10 +3,11 @@ import {ComponentStore} from '@ngrx/component-store'; import {ConfigService} from '@app/components/config/config'; import {HttpApiService} from '@app/lib/api/http_api_service'; import {translateApproval} from '@app/lib/api_translation/client'; -import {combineLatest, Observable, timer} from 'rxjs'; +import {Observable} from 'rxjs'; import {filter, map, switchMap, switchMapTo, tap} from 'rxjs/operators'; import {ClientApproval} from '../lib/models/client'; +import {poll} from '../lib/polling'; import {isNonNull} from '../lib/preconditions'; interface ApprovalPageState { @@ -20,12 +21,6 @@ interface ApprovalKey { readonly requestor: string; } -interface PollingConfig { - readonly pollingIntervalMs: number; - readonly pollingEffect: () => void; - readonly observable: Observable; -} - class BaseComponentStore extends ComponentStore { constructor(state: T) { @@ -39,16 +34,6 @@ class BaseComponentStore extends ComponentStore { protected selectKey(key: K): Observable { return this.select((state) => state[key]); } - - protected poll(config: PollingConfig) { - return combineLatest([ - timer(0, config.pollingIntervalMs).pipe(tap(() => { - config.pollingEffect(); - })), - config.observable, - ]) - .pipe(map(([, value]) => value)); - } } /** @@ -87,15 +72,11 @@ export class ApprovalPageStore extends BaseComponentStore { /** An observable emitting all ScheduledFlows for the client. */ readonly approval$: Observable = - this.poll({ - pollingIntervalMs: - this.configService.config.approvalPollingIntervalMs, - pollingEffect: this.fetchApproval, - observable: this.selectKey('approval'), - }) - .pipe( - filter(isNonNull), - ); + poll({ + pollIntervalMs: this.configService.config.approvalPollingIntervalMs, + pollEffect: this.fetchApproval, + selector: this.selectKey('approval'), + }).pipe(filter(isNonNull)); readonly grantApproval = this.effect( obs$ => obs$.pipe( diff --git a/grr/server/grr_response_server/gui/ui/store/client_page_facade.ts b/grr/server/grr_response_server/gui/ui/store/client_page_facade.ts index 9b2cd189f..098925b67 100644 --- a/grr/server/grr_response_server/gui/ui/store/client_page_facade.ts +++ b/grr/server/grr_response_server/gui/ui/store/client_page_facade.ts @@ -12,12 +12,12 @@ import {catchError, concatMap, distinctUntilChanged, exhaustMap, filter, groupBy import {translateApproverSuggestions} from '../lib/api_translation/user'; import {ApprovalRequest, Client, ClientApproval} from '../lib/models/client'; +import {poll} from '../lib/polling'; import {isNonNull} from '../lib/preconditions'; import {ConfigFacade} from './config_facade'; - interface FlowInConfiguration { readonly name: string; readonly initialArgs?: unknown; @@ -221,19 +221,28 @@ export class ClientPageStore extends ComponentStore { }; }); + private readonly fetchClient = this.effect( + obs$ => obs$.pipe( + switchMapTo(this.select(state => state.clientId)), + filter(isNonNull), + mergeMap(clientId => { + return this.httpApiService.fetchClient(clientId); + }), + map(apiClient => translateClient(apiClient)), + tap(client => { + this.updateSelectedClient(client); + }), + )); + /** An observable emitting the client loaded by `selectClient`. */ readonly selectedClient$: Observable = - combineLatest([ - timer(0, this.configService.config.selectedClientPollingIntervalMs) - .pipe( - tap(() => { - this.fetchClient(); - }), - ), - this.select(state => state.client), - ]) + poll({ + pollIntervalMs: + this.configService.config.selectedClientPollingIntervalMs, + pollEffect: this.fetchClient, + selector: this.select(state => state.client), + }) .pipe( - map(([, client]) => client), filter(isNonNull), shareReplay({bufferSize: 1, refCount: true}), ); @@ -264,37 +273,60 @@ export class ClientPageStore extends ComponentStore { readonly flowInConfiguration$: Observable = this.select(state => state.flowInConfiguration).pipe(filter(isNonNull)); + private readonly listApprovals = this.effect( + obs$ => obs$.pipe( + switchMapTo(this.selectedClientId$), + switchMap(clientId => this.httpApiService.listApprovals(clientId)), + map(apiApprovals => apiApprovals.map(translateApproval)), + tap(approvals => { + this.updateApprovals(approvals); + }))); + /** An observable emitting the latest non-expired approval. */ readonly latestApproval$: Observable = - combineLatest([ - timer(0, this.configService.config.approvalPollingIntervalMs) - .pipe(tap(() => { - this.listApprovals(); - })), - this.select(state => { + poll({ + pollIntervalMs: this.configService.config.approvalPollingIntervalMs, + pollEffect: this.listApprovals, + selector: this.select(state => { // Approvals are expected to be in reversed chronological order. const foundId = state.approvalSequence.find( approvalId => state.approvals[approvalId].status.type !== 'expired'); return foundId ? state.approvals[foundId] : undefined; - }) - ]) + }), + // Only poll when approval is missing or outdated. The user no longer + // benefits from polling when they have a valid approval already. + pollWhile: (approval) => approval?.status.type !== 'valid', + }).pipe(shareReplay({bufferSize: 1, refCount: true})); + + private readonly verifyAccess = this.effect( + obs$ => obs$.pipe( + switchMapTo(this.selectedClientId$), + switchMap( + clientId => this.httpApiService.verifyClientAccess(clientId)), + tap(access => { + this.updateHasAccess(access); + }))); + + readonly hasAccess$: Observable = + poll({ + pollIntervalMs: this.configService.config.approvalPollingIntervalMs, + pollEffect: this.verifyAccess, + selector: this.select(state => state.hasAccess), + // Only poll if hasAccess is undefined or false. In this case, changes + // could happen any time soon. + pollWhile: (hasAccess) => !hasAccess, + }) .pipe( - map(([, approval]) => approval), + filter(isNonNull), shareReplay({bufferSize: 1, refCount: true}), ); - readonly hasAccess$: Observable = - combineLatest([ - timer(0, this.configService.config.approvalPollingIntervalMs) - .pipe(tap(() => { - this.verifyAccess(); - })), - this.select(state => state.hasAccess) - ]) + readonly approvalsEnabled$: Observable = + combineLatest([this.hasAccess$, this.latestApproval$]) .pipe( - map(([, hasAccess]) => hasAccess), - filter(isNonNull), + map(([hasAccess, latestApproval]) => + !hasAccess || latestApproval !== undefined), shareReplay({bufferSize: 1, refCount: true}), ); @@ -303,17 +335,34 @@ export class ClientPageStore extends ComponentStore { return state.flowListEntrySequence.map(id => state.flowListEntries[id]); }); + private readonly listFlows = this.effect( + obs$ => obs$.pipe( + switchMapTo(this.selectedClientId$), + exhaustMap( + clientId => this.httpApiService.listFlowsForClient(clientId).pipe( + catchError(err => { + if (err instanceof MissingApprovalError) { + return EMPTY; + } else { + return throwError(err); + } + }), + )), + map(apiFlows => apiFlows.map(translateFlow)), + tap(flows => { + this.updateFlows(flows); + }), + )); + /** An observable emitting current flow list entries. */ readonly flowListEntries$: Observable> = - combineLatest([ - timer(0, this.configService.config.flowListPollingIntervalMs) - .pipe(tap(() => { - this.listFlows(); - })), - this.flowListEntriesImpl$ - ]) + poll({ + pollIntervalMs: this.configService.config.flowListPollingIntervalMs, + pollEffect: this.listFlows, + selector: this.flowListEntriesImpl$, + }) .pipe( - map(([, entries]) => entries), + shareReplay({bufferSize: 1, refCount: true}), ); @@ -342,35 +391,22 @@ export class ClientPageStore extends ComponentStore { defaultArgs: selectedFlow.initialArgs ?? fd.defaultArgs, }; }), - // Generally, selectedFlow$ emits `undefined` as first value to - // indicate that no flow has been selected. We use startWith() to - // immediately emit this, even though flowDescriptors$ is still - // waiting for the API result. + // Generally, selectedFlow$ emits `undefined` as first value + // to indicate that no flow has been selected. We use + // startWith() to immediately emit this, even though + // flowDescriptors$ is still waiting for the API result. startWith(undefined), shareReplay({bufferSize: 1, refCount: true}), ); - private readonly fetchClient = this.effect( - obs$ => obs$.pipe( - switchMapTo(this.select(state => state.clientId)), - filter(isNonNull), - mergeMap(clientId => { - return this.httpApiService.fetchClient(clientId); - }), - map(apiClient => translateClient(apiClient)), - tap(client => { - this.updateSelectedClient(client); - }), - )); - /** An effect querying results of a given flow. */ private readonly queryFlowResultsImpl = this.effect( obs$ => obs$.pipe( takeUntil(this.selectedClientIdChanged$), withLatestFrom(this.selectedClientId$), - // Below we are grouping the requests by flowId, tag and type, and - // for each group we use a queuedExhaustMap with queue size 1 to send - // a http request for results. + // Below we are grouping the requests by flowId, tag and type, + // and for each group we use a queuedExhaustMap with queue size + // 1 to send a http request for results. groupBy(([query, clientId]) => { return uniqueTagForQuery(query); }), @@ -394,12 +430,15 @@ export class ClientPageStore extends ComponentStore { }), )))); - /** A subject triggered every time the queryFlowResults() method is called. */ + /** + * A subject triggered every time the queryFlowResults() method is + * called. + */ private readonly queryFlowResultsSubject$ = new Subject(); /** - * Triggers flow results query. Results will be automatically updated until - * the flow completes or another client is selected. + * Triggers flow results query. Results will be automatically updated + * until the flow completes or another client is selected. */ queryFlowResults(query: FlowResultsQuery) { this.queryFlowResultsSubject$.next(query); @@ -413,18 +452,19 @@ export class ClientPageStore extends ComponentStore { .pipe( takeUntil(this.selectedClientIdChanged$), takeUntil(this.queryFlowResultsSubject$.pipe( - // If queryFlowResults gets called again for the query for the - // same flow id, tag and type, then stop doing the updates - // (as they'll be done by the subscribed observable initialized - // by the latest queryFlowResults call). + // If queryFlowResults gets called again for the query for + // the same flow id, tag and type, then stop doing the + // updates (as they'll be done by the subscribed + // observable initialized by the latest queryFlowResults + // call). filter( (incomingQuery) => incomingQuery.flowId === query.flowId && incomingQuery.withTag === query.withTag && incomingQuery.withType === query.withType))), takeWhile(([, fleState]) => fleState !== undefined), // Inclusive: the line below will trigger one more time, once - // the flow state becomes FINISHED. This guarantees that results - // will be updated correctly. + // the flow state becomes FINISHED. This guarantees that + // results will be updated correctly. takeWhile(([, fleState]) => fleState !== FlowState.FINISHED, true), map(([i]) => i), distinctUntilChanged(), @@ -541,45 +581,6 @@ export class ClientPageStore extends ComponentStore { }; }); - /** An effect to list approvals. */ - private readonly listApprovals = this.effect( - obs$ => obs$.pipe( - switchMapTo(this.selectedClientId$), - switchMap(clientId => this.httpApiService.listApprovals(clientId)), - map(apiApprovals => apiApprovals.map(translateApproval)), - tap(approvals => { - this.updateApprovals(approvals); - }))); - - private readonly verifyAccess = this.effect( - obs$ => obs$.pipe( - switchMapTo(this.selectedClientId$), - switchMap( - clientId => this.httpApiService.verifyClientAccess(clientId)), - tap(access => { - this.updateHasAccess(access); - }))); - - // An effect to list flows. - private readonly listFlows = this.effect( - obs$ => obs$.pipe( - switchMapTo(this.selectedClientId$), - exhaustMap( - clientId => this.httpApiService.listFlowsForClient(clientId).pipe( - catchError(err => { - if (err instanceof MissingApprovalError) { - return EMPTY; - } else { - return throwError(err); - } - }), - )), - map(apiFlows => apiFlows.map(translateFlow)), - tap(flows => { - this.updateFlows(flows); - }), - )); - /** An effect to add a label to the selected client */ readonly addClientLabel = this.effect( obs$ => obs$.pipe( @@ -623,6 +624,11 @@ export class ClientPageFacade { /** An obserable emitting if the user has access to the client. */ readonly hasAccess$ = this.store.hasAccess$; + /** + * An observable emitting if the approval system is enabled for the user. + */ + readonly approvalsEnabled$ = this.store.approvalsEnabled$; + /** An observable emitting current flow entries. */ readonly flowListEntries$: Observable> = this.store.flowListEntries$; @@ -639,7 +645,6 @@ export class ClientPageFacade { readonly lastRemovedClientLabel$: Observable = this.store.lastRemovedClientLabel$; - /** Selects a client with a given id. */ selectClient(clientId: string): void { this.store.selectClient(clientId); diff --git a/grr/server/grr_response_server/gui/ui/store/client_page_facade_test.ts b/grr/server/grr_response_server/gui/ui/store/client_page_facade_test.ts index 5fdd21367..52a5b9169 100644 --- a/grr/server/grr_response_server/gui/ui/store/client_page_facade_test.ts +++ b/grr/server/grr_response_server/gui/ui/store/client_page_facade_test.ts @@ -198,6 +198,46 @@ describe('ClientPageFacade', () => { expect(await promise).toBeTrue(); })); + it('approvalsEnabled$ emits true if access is false', fakeAsync(async () => { + const promise = firstValueFrom(clientPageFacade.approvalsEnabled$); + tick(configService.config.approvalPollingIntervalMs * 2 + 1); + apiVerifyClientAccess$.next(false); + apiListApprovals$.next([]); + expect(await promise).toBeTrue(); + })); + + it('approvalsEnabled$ emits true if access is true and approval is granted', + fakeAsync(async () => { + const promise = firstValueFrom(clientPageFacade.approvalsEnabled$); + tick(configService.config.approvalPollingIntervalMs * 2 + 1); + apiVerifyClientAccess$.next(false); + apiListApprovals$.next([{ + subject: { + clientId: 'C.1234', + fleetspeakEnabled: false, + knowledgeBase: {}, + labels: [], + age: '0', + }, + id: '2', + reason: '-', + requestor: 'testuser', + isValid: true, + approvers: ['testuser1'], + notifiedUsers: [], + }]); + expect(await promise).toBeTrue(); + })); + + it('approvalsEnabled$ emits false if access is true and no approval exists', + fakeAsync(async () => { + const promise = firstValueFrom(clientPageFacade.approvalsEnabled$); + tick(configService.config.approvalPollingIntervalMs * 2 + 1); + apiVerifyClientAccess$.next(true); + apiListApprovals$.next([]); + expect(await promise).toBeFalse(); + })); + it('calls the listFlow API on flowListEntries$ subscription', fakeAsync(() => { clientPageFacade.flowListEntries$.subscribe(); diff --git a/grr/server/grr_response_server/gui/ui/store/client_page_facade_test_util.ts b/grr/server/grr_response_server/gui/ui/store/client_page_facade_test_util.ts index 3faeeb5ef..08480deed 100644 --- a/grr/server/grr_response_server/gui/ui/store/client_page_facade_test_util.ts +++ b/grr/server/grr_response_server/gui/ui/store/client_page_facade_test_util.ts @@ -18,6 +18,7 @@ export declare interface ClientPageFacadeMock extends readonly lastRemovedClientLabelSubject: Subject; readonly flowListEntriesSubject: Subject>; readonly hasAccessSubject: Subject; + readonly approvalsEnabledSubject: Subject; } export function mockClientPageFacade(): ClientPageFacadeMock { @@ -31,6 +32,7 @@ export function mockClientPageFacade(): ClientPageFacadeMock { const lastRemovedClientLabelSubject = new ReplaySubject(1); const flowListEntriesSubject = new Subject>(); const hasAccessSubject = new Subject(); + const approvalsEnabledSubject = new Subject(); latestApprovalSubject.next(undefined); startFlowStateSubject.next({state: 'request_not_sent'}); @@ -61,5 +63,7 @@ export function mockClientPageFacade(): ClientPageFacadeMock { flowListEntries$: flowListEntriesSubject.asObservable(), hasAccessSubject, hasAccess$: hasAccessSubject.asObservable(), + approvalsEnabledSubject, + approvalsEnabled$: approvalsEnabledSubject.asObservable(), }; } diff --git a/grr/server/grr_response_server/gui/ui/store/scheduled_flow_facade.ts b/grr/server/grr_response_server/gui/ui/store/scheduled_flow_facade.ts index 5afa73a20..fa1282c3a 100644 --- a/grr/server/grr_response_server/gui/ui/store/scheduled_flow_facade.ts +++ b/grr/server/grr_response_server/gui/ui/store/scheduled_flow_facade.ts @@ -4,9 +4,10 @@ import {ConfigService} from '@app/components/config/config'; import {HttpApiService} from '@app/lib/api/http_api_service'; import {translateScheduledFlow} from '@app/lib/api_translation/flow'; import {ScheduledFlow} from '@app/lib/models/flow'; -import {combineLatest, Observable, timer} from 'rxjs'; +import {Observable} from 'rxjs'; import {concatMap, exhaustMap, filter, map, mapTo, shareReplay, tap, withLatestFrom} from 'rxjs/operators'; +import {poll} from '../lib/polling'; import {isNonNull} from '../lib/preconditions'; interface State { @@ -54,18 +55,31 @@ export class ScheduledFlowStore extends ComponentStore { }; }); + private readonly listScheduledFlows = this.effect( + obs$ => obs$.pipe( + withLatestFrom(this.state$), + map(([, {clientId, creator}]) => ({clientId, creator})), + filter( + (args): args is {clientId: string, creator: string} => + isNonNull(args.clientId) && isNonNull(args.creator)), + exhaustMap( + ({creator, clientId}) => + this.httpApiService.listScheduledFlows(clientId, creator)), + map(apiScheduledFlows => + apiScheduledFlows.map(translateScheduledFlow)), + tap(scheduledFlows => { + this.updateScheduledFlows(scheduledFlows); + }), + )); /** An observable emitting all ScheduledFlows for the client. */ readonly scheduledFlows$: Observable> = - combineLatest([ - timer(0, this.configService.config.flowListPollingIntervalMs) - .pipe(tap(() => { - this.listScheduledFlows(); - })), - this.select(state => state.scheduledFlows) - ]) + poll({ + pollIntervalMs: this.configService.config.flowListPollingIntervalMs, + pollEffect: this.listScheduledFlows, + selector: this.select(state => state.scheduledFlows), + }) .pipe( - map(([, entries]) => entries), filter(isNonNull), shareReplay({bufferSize: 1, refCount: true}), ); @@ -98,21 +112,4 @@ export class ScheduledFlowStore extends ComponentStore { sf => sf.scheduledFlowId !== scheduledFlowId) }; }); - - private readonly listScheduledFlows = this.effect( - obs$ => obs$.pipe( - withLatestFrom(this.state$), - map(([, {clientId, creator}]) => ({clientId, creator})), - filter( - (args): args is {clientId: string, creator: string} => - isNonNull(args.clientId) && isNonNull(args.creator)), - exhaustMap( - ({creator, clientId}) => - this.httpApiService.listScheduledFlows(clientId, creator)), - map(apiScheduledFlows => - apiScheduledFlows.map(translateScheduledFlow)), - tap(scheduledFlows => { - this.updateScheduledFlows(scheduledFlows); - }), - )); } diff --git a/grr/server/setup.py b/grr/server/setup.py index d1457d30c..09c05869a 100644 --- a/grr/server/setup.py +++ b/grr/server/setup.py @@ -185,7 +185,7 @@ def make_release_tree(self, base_dir, files): "grr-response-client-builder==%s" % VERSION.get("Version", "packagedepends"), "grr-response-core==%s" % VERSION.get("Version", "packagedepends"), - "Jinja2==2.11.2", + "Jinja2==2.11.3", "pexpect==4.8.0", "portpicker==1.3.1", "prometheus_client==0.8.0",