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 @@
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
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",