date-fns#addMinutes TypeScript Examples

The following examples show how to use date-fns#addMinutes. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: date.ts    From ngx-gantt with MIT License 6 votes vote down vote up
add(amount: number, unit?: GanttDateUtil) {
        switch (unit) {
            case 'second':
                return new GanttDate(this.value).addSeconds(amount);
            case 'minute':
                return new GanttDate(this.value).addMinutes(amount);
            case 'hour':
                return new GanttDate(this.value).addHours(amount);
            case 'day':
                return new GanttDate(this.value).addDays(amount);
            case 'week':
                return new GanttDate(this.value).addWeeks(amount);
            case 'month':
                return new GanttDate(this.value).addMonths(amount);
            case 'quarter':
                return new GanttDate(this.value).addQuarters(amount);
            case 'year':
                return new GanttDate(this.value).addYears(amount);
            default:
                return new GanttDate(this.value).addSeconds(amount);
        }
    }
Example #2
Source File: CheckinDates.tsx    From rcvr-app with GNU Affero General Public License v3.0 6 votes vote down vote up
CheckinDates: React.FC<Props> = ({ from, to, isPast }) => {
  return (
    <Row>
      <Text>{formatDate(from, 'DD.MM.YYYY')}</Text>
      <Box width={1} />
      <Slashes />
      <Box width={1} />
      <Text>{formatDate(from, 'HH:mm')}</Text>
      {(to || isPast) && (
        <>
          <Box width={1} />
          <Arrows />
          <Box width={1} />
          <Text>
            {formatDate(
              to || addMinutes(from, AUTOCHECKOUT_AFTER_MINUTES),
              'HH:mm'
            )}
          </Text>
        </>
      )}
    </Row>
  )
}
Example #3
Source File: SleepChart.tsx    From nyxo-website with MIT License 6 votes vote down vote up
getNightAsDays = (nights: Night[]) => {
  const firstDate = min([...nights.map((night) => new Date(night.startDate))])
  const lastDate = max([...nights.map((night) => new Date(night.endDate))])

  const days = eachDayOfInterval({
    start: subDays(new Date(), 30),
    end: new Date(), // lastDate
  })

  return days.map((day) => ({
    date: day.toISOString(),
    night: nights
      .filter((night) => matchDayAndNight(night.startDate, day.toISOString()))
      .map((night) => {
        const startDifference = differenceInMilliseconds(
          new Date(night.startDate),
          startOfDay(new Date(day))
        )

        const newStartDate = addMilliseconds(
          startOfDay(new Date()),
          startDifference
        )

        const newEndDate = addMinutes(newStartDate, night.totalDuration)

        return {
          ...night,
          startDate: newStartDate.valueOf(),
          endDate: newEndDate.valueOf(),
        }
      }),
  }))
}
Example #4
Source File: nights.ts    From nyxo-app with GNU General Public License v3.0 6 votes vote down vote up
getNightsAsDays = createSelector(getNights, (nights) => {
  const days = eachDayOfInterval({
    start: subDays(new Date(), 30),
    end: new Date() // lastDate
  })

  return days.map((day) => ({
    date: day.toISOString(),
    inBedDuration: 0,
    asleepDuration: 0,
    night: nights
      .filter((night) => matchDayAndNight(night.startDate, day.toISOString()))
      .map((night) => {
        const startDifference = differenceInMilliseconds(
          new Date(night.startDate),
          startOfDay(new Date(day))
        )

        const newStartDate = addMilliseconds(
          startOfDay(new Date()),
          startDifference
        )

        const newEndDate = addMinutes(newStartDate, night.totalDuration)

        return {
          ...night,
          startDate: newStartDate.valueOf(),
          endDate: newEndDate.valueOf()
        }
      })
  }))
})
Example #5
Source File: dateUtils.ts    From ant-extensions with MIT License 5 votes vote down vote up
parseDate = (dt?: string, rounded?: "start" | "end"): ParsedDate => {
  if (dt && isDate(dt)) {
    return parseISO(dt);
  } else if (dt && isDateLike(dt)) {
    const parts = getDateParts(dt);

    if (parts) {
      const { part, op, diff } = parts;
      const diffNum = parseInt(`${op}${diff}`, 10);
      let date = startOfMinute(new Date());

      switch (part) {
        case DateParts.NOW:
          return date;
        case DateParts.DECADE:
          if (rounded) {
            date = (rounded === "start" ? startOfDecade : endOfDecade)(date);
          }
          return addYears(date, diffNum * 10);
        case DateParts.YEAR:
          if (rounded) {
            date = (rounded === "start" ? startOfYear : endOfYear)(date);
          }
          return addYears(date, diffNum);
        case DateParts.QUARTER:
          if (rounded) {
            date = (rounded === "start" ? startOfQuarter : endOfQuarter)(date);
          }
          return addQuarters(date, diffNum);
        case DateParts.MONTH:
          if (rounded) {
            date = (rounded === "start" ? startOfMonth : endOfMonth)(date);
          }
          return addMonths(date, diffNum);
        case DateParts.WEEK:
          if (rounded) {
            date = (rounded === "start" ? startOfWeek : endOfWeek)(date);
          }
          return addWeeks(date, diffNum);
        case DateParts.DAY:
          if (rounded) {
            date = (rounded === "start" ? startOfDay : endOfDay)(date);
          }
          return addDays(date, diffNum);
        case DateParts.HOUR:
          if (rounded) {
            date = (rounded === "start" ? startOfHour : endOfHour)(date);
          }
          return addHours(date, diffNum);
        case DateParts.MINUTE:
          if (rounded) {
            date = (rounded === "start" ? startOfMinute : endOfMinute)(date);
          }
          return addMinutes(date, diffNum);
      }
    }
  }
  return undefined;
}
Example #6
Source File: date.ts    From ngx-gantt with MIT License 5 votes vote down vote up
addMinutes(amount: number): GanttDate {
        return new GanttDate(addMinutes(this.value, amount));
    }
Example #7
Source File: quickShortcuts.ts    From TidGi-Desktop with Mozilla Public License 2.0 5 votes vote down vote up
quickShortcuts = [
  {
    name: '15 minutes',
    calcDate: () => addMinutes(new Date(), 15),
  },
  {
    name: '30 minutes',
    calcDate: () => addMinutes(new Date(), 30),
  },
  {
    name: '45 minutes',
    calcDate: () => addMinutes(new Date(), 45),
  },
  {
    name: '1 hour',
    calcDate: () => addHours(new Date(), 1),
  },
  {
    name: '2 hours',
    calcDate: () => addHours(new Date(), 2),
  },
  {
    name: '4 hours',
    calcDate: () => addHours(new Date(), 4),
  },
  {
    name: '6 hours',
    calcDate: () => addHours(new Date(), 6),
  },
  {
    name: '8 hours',
    calcDate: () => addHours(new Date(), 8),
  },
  {
    name: '10 hours',
    calcDate: () => addHours(new Date(), 8),
  },
  {
    name: '12 hours',
    calcDate: () => addHours(new Date(), 12),
  },
  {
    name: 'Until tomorrow',
    calcDate: () => addDays(new Date(), 1),
  },
  {
    name: 'Until next week',
    calcDate: () => addWeeks(new Date(), 1),
  },
]
Example #8
Source File: timetable.page.ts    From radiopanel with GNU General Public License v3.0 5 votes vote down vote up
public startDragToCreate(
		segment: any,
		mouseDownEvent: MouseEvent,
		segmentElement: HTMLElement
	) {
		const { tenant } = this.sessionQuery.getValue();

		const temporarySlot: CalendarEvent = {
			id: this.slots.length,
			title: 'New slot',
			start: segment.date,
			end: moment(segment.date).add((tenant?.settings?.minimumSlotDuration || 30) > 60 ? (tenant?.settings?.minimumSlotDuration || 30) : 60, 'minutes').toDate(),
			color: {
				primary: '#000',
				secondary: '#000'
			},
			meta: {
				tmpEvent: true,
				...this.user
			}
		};

		this.slots = [...this.slots, temporarySlot];
		const scrollOffset = document.querySelector('.o-content').scrollTop;
		const segmentPosition = segmentElement.getBoundingClientRect();
		this.dragToCreateActive = true;
		const endOfView = endOfWeek(this.viewDate, {
			weekStartsOn: 1
		});

		fromEvent(document, 'mousemove')
			.pipe(
				finalize(() => {
					delete temporarySlot.meta.tmpEvent;
					this.dragToCreateActive = false;
					this.handleSlotCreate(temporarySlot);
					this.refresh();
				}),
				takeUntil(fromEvent(document, 'mouseup')),
				// debounceTime(10)
			)
			.subscribe((mouseMoveEvent: MouseEvent) => {
				const boundingRect = this.timetableRoot.nativeElement.getBoundingClientRect();
				const minutesDiff = ceilToNearest(
					mouseMoveEvent.clientY - segmentPosition.top - (boundingRect.top - this.timetableRoot.nativeElement.offsetTop) - scrollOffset,
					30
				);

				const daysDiff =
					floorToNearest(
						mouseMoveEvent.clientX - segmentPosition.left,
						segmentPosition.width
					) / segmentPosition.width;

				const newEnd = addDays(
					addMinutes(segment.date, minutesDiff),
					daysDiff
				);

				const totalDiff = (minutesDiff + (daysDiff * 24 * 60));

				if (newEnd > segment.date && newEnd < moment(endOfView).add(1, 'minute').toDate() && totalDiff <= (tenant?.settings?.maximumSlotDuration || 1440) && totalDiff >= (tenant?.settings?.minimumSlotDuration || 30)) {
					temporarySlot.end = newEnd;
				}

				this.refresh();
			});
	}
Example #9
Source File: index.tsx    From livepeer-com with MIT License 5 votes vote down vote up
formatFilterItemFromQueryParam = (
  filter: FilterItem,
  queryParamValue: string
): Filter => {
  const decodedValue = decodeURIComponent(queryParamValue);
  switch (filter.type) {
    case "text":
      return {
        ...filter,
        isOpen: true,
        condition: {
          type: "contains",
          value: decodedValue,
        },
      };
    case "boolean":
      return {
        ...filter,
        isOpen: true,
        condition: { type: "boolean", value: decodedValue === "true" },
      };
    case "date": {
      const splitted = decodedValue.split(",");
      const isDateBetween = splitted.length > 1;
      const timezoneOffset = new Date().getTimezoneOffset();
      return {
        ...filter,
        isOpen: true,
        // @ts-ignore
        condition: {
          type: isDateBetween ? "dateBetween" : "dateEqual",
          value: isDateBetween
            ? (splitted.map((s) =>
                format(
                  addMinutes(new Date(parseInt(s)), timezoneOffset),
                  "yyyy-MM-dd"
                )
              ) as [string, string])
            : format(
                addMinutes(new Date(parseInt(splitted[0])), timezoneOffset),
                "yyyy-MM-dd"
              ),
        },
      };
    }
    case "number": {
      const splitted1 = decodedValue.split(",");
      const isNumberBetween = splitted1.length > 1;
      return {
        ...filter,
        isOpen: true,
        // @ts-ignore
        condition: {
          type: isNumberBetween ? "numberBetween" : "numberEqual",
          value: isNumberBetween
            ? (splitted1.map((s) => parseInt(s)) as [number, number])
            : parseInt(splitted1[0]),
        },
      };
    }
    default:
      break;
  }
}
Example #10
Source File: sleep-data-helper.ts    From nyxo-app with GNU General Public License v3.0 5 votes vote down vote up
export function calculateBedtimeWindow(
  lastSevenDays: Day[]
): {
  goToSleepWindowStart: string
  goToSleepWindowCenter: string
  goToSleepWindowEnd: string
} {
  let averageBedTime = 0
  let divideBy = 0
  lastSevenDays.forEach((day) => {
    const dayStarted = new Date(day.date) // Beginning of the day
    if (day.bedStart) {
      const bedTimeStart = new Date(day.bedStart)

      const totalDifference = differenceInMinutes(bedTimeStart, dayStarted)
      // Add difference to the average time
      averageBedTime += totalDifference
      // increment divider
      divideBy += 1
    }
  })

  if (divideBy !== 0) {
    averageBedTime /= divideBy
  }

  // Remove the extra 24 hours
  if (averageBedTime > 1440) {
    averageBedTime = -1440
  }

  const bedTimeWindowCenter = roundToNearestMinutes(
    setMinutes(startOfDay(new Date()), averageBedTime),
    {
      nearestTo: 15
    }
  ).toISOString()

  const bedTimeWindowStart = subMinutes(
    new Date(bedTimeWindowCenter),
    45
  ).toISOString()

  const bedTimeWindowEnd = addMinutes(
    new Date(bedTimeWindowCenter),
    45
  ).toISOString()

  const insights = {
    goToSleepWindowStart: bedTimeWindowStart,
    goToSleepWindowCenter: bedTimeWindowCenter,
    goToSleepWindowEnd: bedTimeWindowEnd
  }

  return insights
}
Example #11
Source File: oura-helper.ts    From nyxo-app with GNU General Public License v3.0 5 votes vote down vote up
calculateAsleepPeriods = (start: string, hypnogram: string): Night[] => {
  const asleepSamples: Night[] = []
  let startTime = new Date(start)
  let endTime = addMinutes(new Date(start), 5)
  let asleep = false
  let i

  for (i = 0; i < hypnogram.length; i += 1) {
    // if asleep and wakes up, add new sleep sample
    if (asleep && hypnogram[i] === '4') {
      asleepSamples.push({
        id: `oura_${startTime.toISOString()}_${endTime.toISOString()}`,
        totalDuration: getNightDuration(
          startTime.toISOString(),
          endTime.toISOString()
        ),
        sourceName: 'Oura',
        sourceId: CONFIG.OURA_CONFIG.bundleId,
        value: Value.Asleep,
        startDate: startTime.toISOString(),
        endDate: endTime.toISOString()
      })

      asleep = false
      startTime = addMinutes(endTime, 5)
      endTime = addMinutes(startTime, 5)
    }

    // 4 == awake
    // if awake move both start and end time
    else if (!asleep && hypnogram[i] === '4') {
      asleep = false
      startTime = addMinutes(startTime, 5)
      endTime = addMinutes(startTime, 5)
    }

    // if awake and hyphogram shows sleep set asleep
    else if (!asleep && hypnogram[i] !== '4') {
      asleep = true
    }

    // is asleep and hypnogram shows sleep increment endTime
    else if (asleep && hypnogram[i] !== '4') {
      endTime = addMinutes(endTime, 5)
    }
  }

  return asleepSamples
}
Example #12
Source File: keep-data-range-test.spec.ts    From js-client with MIT License 5 votes vote down vote up
makeKeepDataRangeTest = ({
	start,
	end,
	count,
	createSearch,
}: {
	start: Date;
	end: Date;
	count: number;
	createSearch: CreateSearchFn;
}) => {
	return async () => {
		const initialFilter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

		const search = await createSearch(initialFilter);
		// Expect the filter to be the initial one
		await expectStatsFilter(search.stats$, initialFilter);

		////
		// Update property
		////
		const updatedDateRange = { dateRange: { start: start, end: addMinutes(end, 10000) } };
		const entriesUpdatedFilter = { entriesOffset: { index: 0, count: count / 2 } };

		search.setFilter(updatedDateRange);
		// Expect the filter to be the updatedDateRange
		await expectStatsFilter(search.stats$, updatedDateRange);

		// Update twice to clear previous cache
		search.setFilter(entriesUpdatedFilter);
		// Expect the filter data range to still be the updatedDateRange
		await expectStatsFilter(search.stats$, entriesUpdatedFilter);

		search.setFilter(entriesUpdatedFilter);
		// Expect the filter data range to still be the updatedDateRange
		await expectStatsFilter(search.stats$, entriesUpdatedFilter);

		////
		// Check filter
		////
		const stats = await firstValueFrom(search.stats$);
		expect(stats.filter)
			.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
			.toPartiallyEqual(updatedDateRange);
	};
}
Example #13
Source File: stop-one-search.spec.ts    From js-client with MIT License 5 votes vote down vote up
describe('stopOneSearch()', () => {
	const getPersistentSearchStatusRelatedToMe = makeGetPersistentSearchStatusRelatedToMe(TEST_BASE_API_CONTEXT);
	const saveOneSearch = makeSaveOneSearch(TEST_BASE_API_CONTEXT);
	const getOnePersistentSearchStatus = makeGetOnePersistentSearchStatus(TEST_BASE_API_CONTEXT);

	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count - 1);

	beforeAll(async () => {
		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	xit(
		'Should stop a search',
		integrationTest(async () => {
			const stopOneSearch = makeStopOneSearch(TEST_BASE_API_CONTEXT);
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} sleep 40ms`;
			const search = await subscribeToOneSearch(query, {
				filter: { entriesOffset: { index: 0, count: count }, dateRange: { start, end } },
			});

			expect(async () => await stopOneSearch(search.searchID)).not.toThrow();
		}),
		25000,
	);
});
Example #14
Source File: generate-auto-extractors.spec.ts    From js-client with MIT License 5 votes vote down vote up
describe('generateAutoExtractors()', () => {
	const generateAutoExtractors = makeGenerateAutoExtractors(TEST_BASE_API_CONTEXT);

	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count - 1);

	beforeAll(async () => {
		// Generate and ingest some entries
		const ingestJSONEntries = makeIngestJSONEntries(TEST_BASE_API_CONTEXT);
		const values: Array<CreatableJSONEntry> = range(0, count).map(i => {
			const timestamp = addMinutes(start, i).toISOString();
			return {
				timestamp,
				tag,
				data: JSON.stringify({ timestamp, value: i }, null, 2), // Add vertical whitespace, so that JSON is recommended over CSV
			};
		});

		await ingestJSONEntries(values);

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should generate auto extractors',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} limit 10`;
			const search = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });

			const entries = await lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const exploreResults = await generateAutoExtractors({ tag, entries });

			expect(Object.keys(exploreResults).length).withContext('we should get >0 AX suggestions').toBeGreaterThan(0);
			expect(exploreResults['json']).withContext('we should have a JSON AX suggestion').toBeDefined();
			expect(exploreResults['json'].length).withContext('we should have >0 JSON AX suggestions').toBeGreaterThan(0);

			exploreResults['json'].forEach(ax => {
				expect(ax.autoExtractor.tag).withContext('the suggested AX tag should match the provided tag').toEqual(tag);
				expect(ax.autoExtractor.module).withContext('the suggested AX module should be json').toEqual('json');
				expect(ax.confidence).withContext('json is the right module, so its confidence should be 10').toEqual(10);
				expect(ax.autoExtractor.parameters)
					.withContext('the suggested AX module should break out the fields in the entries')
					.toEqual('timestamp value');
				expect(ax.explorerEntries.length).withContext('explore should have >0 elements').toBeGreaterThan(0);
				ax.explorerEntries.forEach(exploreEntry => {
					expect(exploreEntry.elements.length)
						.withContext('explore should have two elements: one for each field (timestamp and value)')
						.toEqual(2);
					exploreEntry.elements.forEach(elt => {
						expect(elt.filters.length).withContext('we should have filters on explore elements').toBeGreaterThan(0);
					});
				});
			});

			// We can't really make assertions about what the other AX generators are going to do when we look at JSON data
		}),
		25000,
	);
});
Example #15
Source File: dnd.ts    From apps with GNU Affero General Public License v3.0 5 votes vote down vote up
dndOption: { [K in TimeFormat]: DndOption<K> } = {
  HALF_HOUR: {
    value: 30,
    label: '30 minutes',
    getExpiration(): Date {
      return addMinutes(new Date(), this.value);
    },
  },
  ONE_HOUR: {
    value: 1,
    label: '1 hour',
    getExpiration(): Date {
      return addHours(new Date(), this.value);
    },
  },
  TWO_HOURS: {
    value: 2,
    label: '2 hours',
    getExpiration(): Date {
      return addHours(new Date(), this.value);
    },
  },
  TOMORROW: {
    value: 1,
    label: 'Tomorrow',
    getExpiration(): Date {
      return addDays(new Date(), this.value);
    },
  },
  CUSTOM: {
    value: 0,
    label: 'Custom...',
    getExpiration(time: CustomTime, value: number): Date {
      const exp = new Date();

      if (time === CustomTime.DAYS) return addDays(exp, value);

      if (time === CustomTime.HOURS) return addHours(exp, value);

      return addMinutes(exp, value);
    },
  },
}
Example #16
Source File: settings.service.ts    From xBull-Wallet with GNU Affero General Public License v3.0 5 votes vote down vote up
enableKeepPasswordOption(): void {
    this.settingsStore.update(state => ({
      ...state,
      keepPasswordActive: true,
      lastTimePasswordSaved: new Date(),
      nextTimeToRemovePassword: addMinutes(new Date(), state.timeoutPasswordSaved),
    }));
  }
Example #17
Source File: subscribe-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json $${macroName} | count`;
			const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };
			const search = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);

			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searches = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			// Start a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(subscribeToOneSearch(`tag=${tag} regex ${x}`, { filter }))
						.withContext('good query should resolve')
						.toBeResolved(),
				),
			);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(takeWhile(e => datesAreEqual(e.start, start) === false, true)),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };
				const search = await subscribeToOneSearch(query, { filter: { dateRange } });

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };
				const search1s = await subscribeToOneSearch(query1s, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };
				const search33s = await subscribeToOneSearch(query33s, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter1);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};
				const search = await subscribeToOneSearch(query, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;

						return await subscribeToOneSearch(query, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #18
Source File: subscribe-to-one-explorer-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('subscribeToOneExplorerSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: { foo: i } };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}

		// Create an AX definition for the generated tag
		const createOneAutoExtractor = makeCreateOneAutoExtractor(TEST_BASE_API_CONTEXT);
		await createOneAutoExtractor({
			tag: tag,
			name: `${tag} - JSON`,
			description: '-',
			module: 'json',
			parameters: 'timestamp value value.foo',
		});
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const search = await subscribeToOneExplorerSearch(query, { filter: { dateRange: { start, end } } });

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} ax | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			const explorerEntries = textEntries.explorerEntries;
			expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
				.withContext('Expect a promise of an array of data explorer entries')
				.toBeTrue();
			expect(explorerEntries.length).withContext(`Expect ${count} entries`).toBe(count);

			for (const entry of explorerEntries) {
				expect(entry.tag).withContext(`Expect entry tag to be "${tag}"`).toBe(tag);

				expect(entry.elements.length)
					.withContext(`Expect to have 2 data explorer elements on first depth level`)
					.toBe(2);
				expect(entry.elements.map(el => el.name).sort())
					.withContext(`Expect first depth data explorer elements to be "value" and "timestamp"`)
					.toEqual(['timestamp', 'value']);
				expect(entry.elements.map(el => el.module))
					.withContext(`Expect explorer module to be JSON`)
					.toEqual(['json', 'json']);

				const timestampEl = entry.elements.find(el => el.name === 'timestamp')!;
				const valueEl = entry.elements.find(el => el.name === 'value')!;

				expect(timestampEl.children.length).withContext(`Expect the timestamp element to not have children`).toBe(0);
				expect(valueEl.children.length).withContext(`Expect the value element to have one children`).toBe(1);
				expect(valueEl.children[0].name)
					.withContext(`Expect the value element child to be value.foo`)
					.toBe('value.foo');
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp');
				const _value = enumeratedValues.find(v => v.name === 'value');

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value.foo)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it('Should be able to apply element filters', async () => {
		const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

		const unfilteredQuery = `tag=${tag} raw`;
		const elementFilters: Array<ElementFilter> = [
			{ path: 'value.foo', operation: '!=', value: '50', tag, module: 'json', arguments: null },
		];
		const query = `tag=${tag} json "value.foo" != "50" as "foo" | raw`;
		const countAfterFilter = count - 1;

		const filter: SearchFilter = {
			entriesOffset: { index: 0, count: count },
			elementFilters,
			dateRange: { start, end },
		};
		const search = await subscribeToOneExplorerSearch(unfilteredQuery, { filter });

		const textEntriesP = lastValueFrom(
			search.entries$.pipe(
				map(e => e as RawSearchEntries & { explorerEntries: Array<DataExplorerEntry> }),
				takeWhile(e => !e.finished, true),
			),
		);

		const statsP = lastValueFrom(
			search.stats$.pipe(
				takeWhile(e => !e.finished, true),
				toArray(),
			),
		);

		const [textEntries, stats] = await Promise.all([textEntriesP, statsP]);

		////
		// Check entries
		////
		expect(textEntries.data.length)
			.withContext('The number of entries should equal the total ingested')
			.toEqual(countAfterFilter);

		if (isUndefined(textEntries.filter) === false) {
			expect(textEntries.filter)
				.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
				.toPartiallyEqual(filter);
		}

		const explorerEntries = textEntries.explorerEntries;
		expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
			.withContext('Expect a promise of an array of data explorer entries')
			.toBeTrue();
		expect(explorerEntries.length).withContext(`Expect ${countAfterFilter} entries`).toBe(countAfterFilter);

		////
		// Check stats
		////
		expect(stats.length).toBeGreaterThan(0);
		expect(stats[0].query).toBe(query);
	});

	it(
		'Should reject on a bad query string',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `this is an invalid query`;
			const range: [Date, Date] = [start, end];
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject on a bad query range (end is before start)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: { start, end: subMinutes(start, 10) },
			};

			await expectAsync(subscribeToOneExplorerSearch(query, { filter })).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (different queries)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions with different queries to race them
			await Promise.all([
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "a"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "b"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "c"`, { filter: { ...baseFilter, dateRange: goodRange } }),
				)
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "d"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(
					subscribeToOneExplorerSearch(`tag=${tag} regex "e"`, { filter: { ...baseFilter, dateRange: badRange } }),
				)
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should reject bad searches without affecting good ones (same query)',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag}`;
			const goodRange = { start, end };
			const badRange = { start, end: subMinutes(start, 10) };
			const baseFilter: SearchFilter = { entriesOffset: { index: 0, count: count } };

			// Start a bunch of search subscriptions to race them
			await Promise.all([
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: goodRange } }))
					.withContext('good query should resolve')
					.toBeResolved(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
				expectAsync(subscribeToOneExplorerSearch(query, { filter: { ...baseFilter, dateRange: badRange } }))
					.withContext('query with bad range should reject')
					.toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should send error over error$ when Last is less than First',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} chart`;

			// Use an invalid filter, where Last is less than First
			const filter: SearchFilter = { entriesOffset: { index: 1, count: -1 }, dateRange: { start, end } };

			const search = await subscribeToOneExplorerSearch(query, { filter });

			// Non-error observables should error
			await Promise.all([
				expectAsync(lastValueFrom(search.progress$)).withContext('progress$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.entries$)).withContext('entries$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.stats$)).withContext('stats$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsOverview$)).withContext('statsOverview$ should error').toBeRejected(),
				expectAsync(lastValueFrom(search.statsZoom$)).withContext('statsZoom$ should error').toBeRejected(),
			]);

			// errors$ should emit one item (the error) and resolve
			const error = await lastValueFrom(search.errors$);

			expect(error).toBeDefined();
			expect(error.name.length).toBeGreaterThan(0);
			expect(error.message.length).toBeGreaterThan(0);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer and preview flag',
		integrationTest(async () => {
			const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);
			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = {
				entriesOffset: { index: 0, count: count },
				dateRange: 'preview',
			};
			const search = await subscribeToOneExplorerSearch(query, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should be less than the total ingested for preview mode')
				.toBeLessThan(count);
			expect(textEntries.data.length).withContext('The number of entries should be more than zero').toBeGreaterThan(0);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			// Zip the results with the orignal, slicing the original to the length of the results, since
			// the preview flag limits the number of results we get back
			const trimmedOriginal = reversedData.slice(0, textEntries.data.length);
			expect(trimmedOriginal.length)
				.withContext('Lengths should match (sanity check)')
				.toEqual(textEntries.data.length);

			expect(
				zip(trimmedOriginal.slice(0, trimmedOriginal.length - 1), trimmedOriginal.slice(1)).reduce(
					(isDesc, [prev, cur]) => {
						if (prev === undefined || cur === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						return prev.value.foo > cur.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('original (trimmed and reversed) data should have values in descending order')
				.toBeTrue();

			expect(
				zip(textEntries.data.slice(0, textEntries.data.length - 1), textEntries.data.slice(1)).reduce(
					(isDesc, [prevEntry, curEntry]) => {
						if (prevEntry === undefined || curEntry === undefined) {
							throw new Error('Zipped values were not the same length.');
						}
						const prevValue: Entry = JSON.parse(base64.decode(prevEntry.data));
						const curValue: Entry = JSON.parse(base64.decode(curEntry.data));

						return prevValue.value.foo > curValue.value.foo && isDesc;
					},
					true,
				),
			)
				.withContext('received entry data should have values in descending order')
				.toBeTrue();

			zip(textEntries.data, trimmedOriginal).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail("All data should be defined, since we've sliced the original data to match the preview results");
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const [_timestamp, _value] = enumeratedValues;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value)
					.withContext(`Each entry should have an enumerated value called "value"`)
					.toEqual({
						isEnumerated: true,
						name: 'value',
						value: JSON.stringify(original.value),
					});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual({ foo: count - index - 1 });
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext(
					'The sum of counts from statsOverview should be less than the total count ingested in preview mode',
				)
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsOverview.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsOverview should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should be less than the total count ingested in preview mode')
				.toBeLessThan(count);
			// TODO include this test when backend is ready
			// expect(sum(statsZoom.frequencyStats.map(x => x.count)))
			// 	.withContext('The sum of counts from statsZoom should equal the number of results returned by preview mode')
			// 	.toEqual(textEntries.data.length);

			// See if we can change the date range
			const lastEntriesP = lastValueFrom(
				search.entries$.pipe(
					takeWhile(e => datesAreEqual(e.start, start) === false, true),
					last(),
				),
			);
			search.setFilter({ dateRange: { start, end } });
			const lastEntries = await lastEntriesP;

			expect(datesAreEqual(lastEntries.start, start))
				.withContext(`Start date should be the one we just set`)
				.toBeTrue();
			expect(datesAreEqual(lastEntries.end, end)).withContext(`End date should be the one we just set`).toBeTrue();
		}),
		25000,
	);

	it(
		'Should keep the dateRange when update the filter multiple times',
		integrationTest(
			makeKeepDataRangeTest({
				start,
				end,
				count,
				createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
					const subscribeToOneExplorerSearch = makeSubscribeToOneExplorerSearch(TEST_BASE_API_CONTEXT);

					const query = `tag=*`;

					return await subscribeToOneExplorerSearch(query, { filter: initialFilter });
				},
			}),
		),
		25000,
	);
});
Example #19
Source File: search-renderers.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('search renderer types', () => {
	// Make function to subscript to a search
	const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);

	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = {
				category: sample<Entry['category']>(['red', 'blue', 'green']) ?? 'red',
				value: random(0, 100),
				fixed: 10,
				ip: randomIp(),
				location: randomCoordinate(),
				srclocation: randomCoordinate(),
				dstlocation: randomCoordinate(),
				timestamp: addMinutes(start, i).toISOString(),
			};
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
		await sleep(5000);
	}, 25000);

	it(
		'should search using the point2point renderer',
		integrationTest(async () => {
			// Perform a query, rendering it as point2point
			const query = `tag=${tag} json srclocation.lat as slat srclocation.lon as slon dstlocation.lat as dlat dstlocation.lon as dlon | point2point -srclat slat -srclong slon -dstlat dlat -dstlong dlon`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			// Wait on the entries to come back
			const entries = await lastValueFrom(search.entries$.pipe(takeWhile(e => !e.finished, true)));

			// Check the type
			expect(entries.type).toEqual('point2point');

			// Assert type and make some basic sanity checks
			const point2PointEntries = entries as PointToPointSearchEntries;
			expect(point2PointEntries.finished).toBeTrue();
			expect(point2PointEntries.data.length).toEqual(count);
		}),
		25000,
	);

	it(
		'should work with queries using the hex renderer, which behaves like the raw renderer',
		integrationTest(async () => {
			// Perform a query, rendering it as hex
			const query = `tag=${tag} hex`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			// Wait on the entries to come back
			const entries = await lastValueFrom(search.entries$.pipe(takeWhile(e => !e.finished, true)));

			// Check the type
			expect(entries.type).toEqual('hex');

			// Assert type and make some basic sanity checks
			const hexEntries = entries as HexSearchEntries;
			expect(hexEntries.finished).toBeTrue();
			expect(hexEntries.data.length).toEqual(count);
		}),
		25000,
	);

	it(
		'should work with queries using the pcap renderer, which behaves like the text renderer',
		integrationTest(async () => {
			// Perform a query, rendering it as pcap
			const query = `tag=${tag} pcap`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			// Wait on the entries to come back
			const entries = await lastValueFrom(search.entries$.pipe(takeWhile(e => !e.finished, true)));

			// Check the type
			expect(entries.type).toEqual('pcap');

			// Assert type and make some basic sanity checks
			const pcapEntries = entries as PcapSearchEntries;
			expect(pcapEntries.finished).toBeTrue();
			expect(pcapEntries.data.length).toEqual(count);
		}),
		25000,
	);

	it(
		'should work with queries using the stackgraph renderer',
		integrationTest(async () => {
			// Perform a query, rendering it as a stackgraph
			const query = `tag=${tag}  json category fixed value |  sum value by category,fixed | stackgraph category fixed sum`;
			const filter: SearchFilter = { dateRange: { start, end } };
			const search = await subscribeToOneSearch(query, { filter });

			// Wait on the entries to come back
			const entries = await lastValueFrom(search.entries$.pipe(takeWhile(e => !e.finished, true)));

			// Check the type
			expect(entries.type).toEqual('stackgraph');

			// Assert type and make some basic sanity checks
			const stackGraphEntries = entries as StackGraphSearchEntries;
			expect(stackGraphEntries.finished).toBeTrue();
			expect(stackGraphEntries.data.length).toEqual(3); // the three categories: red, green, blue
		}),
		25000,
	);
});
Example #20
Source File: modify-one-query.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('modifyOneQuery()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count - 1);

	beforeAll(async () => {
		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: { foo: i } };
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}

		// Create an AX definition for the generated tag
		const createOneAutoExtractor = makeCreateOneAutoExtractor(TEST_BASE_API_CONTEXT);
		await createOneAutoExtractor({
			tag: tag,
			name: `${tag} - JSON`,
			description: '-',
			module: 'json',
			parameters: 'value value.foo',
		});
	}, 25000);

	it(
		'Should return a new query with the filter applied',
		integrationTest(async () => {
			const validateOneQuery = makeValidateOneQuery(TEST_BASE_API_CONTEXT);
			const modifyOneQuery = makeModifyOneQuery(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} table`;
			const validation = await validateOneQuery(query);
			expect(validation.isValid).withContext(`Expect initial query to be valid`).toBeTrue();

			const filter: ElementFilter = {
				path: 'value.foo',
				operation: '==',
				value: '50',
				tag,
				module: 'json',
				arguments: null,
			};
			const newQuery = await modifyOneQuery(query, [filter]);
			const newValidation = await validateOneQuery(newQuery);
			expect(newValidation.isValid).withContext(`Expect new query to be valid`).toBeTrue();

			expect(newQuery).withContext(`Expect new query to be different than initial one`).not.toBe(query);
			expect(newQuery)
				.withContext(`Expect new query to contain the applied filter`)
				.toBe(`tag=${tag} json "value.foo" == "50" as "foo" | table`);
		}),
	);

	it(
		'Should throw if the filters are invalid',
		integrationTest(async () => {
			const validateOneQuery = makeValidateOneQuery(TEST_BASE_API_CONTEXT);
			const modifyOneQuery = makeModifyOneQuery(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} table`;
			const validation = await validateOneQuery(query);
			expect(validation.isValid).withContext(`Expect initial query to be valid`).toBeTrue();

			const filter: ElementFilter = {
				path: 'Src',
				operation: '==',
				value: '50',
				tag,
				module: 'netflow',
				arguments: null,
			};
			await expectAsync(modifyOneQuery(query, [filter]))
				.withContext(`Expect invalid filter to cause an error`)
				.toBeRejectedWithError(Error, 'netflow (module idx 0) error: Malformed IPv6 Address');
		}),
	);
});
Example #21
Source File: explore-one-tag.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('exploreOneTag()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	beforeAll(async () => {
		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: { foo: i } };
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}

		// Create an AX definition for the generated tag
		const createOneAutoExtractor = makeCreateOneAutoExtractor(TEST_BASE_API_CONTEXT);
		await createOneAutoExtractor({
			tag: tag,
			name: `${tag} - JSON`,
			description: '-',
			module: 'json',
			parameters: 'value value.foo',
		});
	}, 25000);

	it(
		'Should return data explorer entries',
		integrationTest(async () => {
			const exploreOneTag = makeExploreOneTag(TEST_BASE_API_CONTEXT);
			const explorerEntries = await exploreOneTag(tag, { range: [start, end] });

			expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
				.withContext('Expect a promise of an array of data explorer entries')
				.toBeTrue();
			expect(explorerEntries.length).withContext(`Expect ${count} entries`).toBe(count);

			for (const entry of explorerEntries) {
				expect(entry.tag).withContext(`Expect entry tag to be "${tag}"`).toBe(tag);

				expect(entry.elements.length)
					.withContext(`Expect to have 2 data explorer elements on first depth level`)
					.toBe(2);
				expect(entry.elements.map(el => el.name).sort())
					.withContext(`Expect first depth data explorer elements to be "value" and "timestamp"`)
					.toEqual(['timestamp', 'value']);
				expect(entry.elements.map(el => el.module))
					.withContext(`Expect explorer module to be JSON`)
					.toEqual(['json', 'json']);

				const timestampEl = entry.elements.find(el => el.name === 'timestamp')!;
				const valueEl = entry.elements.find(el => el.name === 'value')!;

				expect(timestampEl.children.length).withContext(`Expect the timestamp element to not have children`).toBe(0);
				expect(valueEl.children.length).withContext(`Expect the value element to have one children`).toBe(1);
				expect(valueEl.children[0].name)
					.withContext(`Expect the value element child to be value.foo`)
					.toBe('value.foo');
			}
		}),
		25000,
	);

	it(
		'Should respect limit options',
		integrationTest(async () => {
			const limit = 150;
			const exploreOneTag = makeExploreOneTag(TEST_BASE_API_CONTEXT);
			const explorerEntries = await exploreOneTag(tag, { range: [start, end], limit });

			expect(isArray(explorerEntries) && explorerEntries.every(isDataExplorerEntry))
				.withContext('Expect a promise of an array of data explorer entries')
				.toBeTrue();
			expect(explorerEntries.length).withContext(`Expect ${limit} entries`).toBe(limit);
		}),
		25000,
	);
});
Example #22
Source File: attach-to-one-search.spec.ts    From js-client with MIT License 4 votes vote down vote up
describe('attachToOneSearch()', () => {
	// Use a randomly generated tag, so that we know exactly what we're going to query
	const tag = uuidv4();

	// The number of entries to generate
	const count = 1000;

	// The start date for generated queries
	const start = new Date(2010, 0, 0);

	// The end date for generated queries; one minute between each entry
	const end = addMinutes(start, count);

	const originalData: Array<Entry> = [];

	beforeAll(async () => {
		jasmine.addMatchers(myCustomMatchers);

		// Generate and ingest some entries
		const ingestMultiLineEntry = makeIngestMultiLineEntry(TEST_BASE_API_CONTEXT);
		const values: Array<string> = [];
		for (let i = 0; i < count; i++) {
			const value: Entry = { timestamp: addMinutes(start, i).toISOString(), value: i };
			originalData.push(value);
			values.push(JSON.stringify(value));
		}
		const data: string = values.join('\n');
		await ingestMultiLineEntry({ data, tag, assumeLocalTimezone: false });

		// Check the list of tags until our new tag appears
		const getAllTags = makeGetAllTags(TEST_BASE_API_CONTEXT);
		while (!(await getAllTags()).includes(tag)) {
			// Give the backend a moment to catch up
			await sleep(1000);
		}
	}, 25000);

	it(
		'Should complete the observables when the search closes',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag}`;
			const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			let complete = 0;
			const observables: Array<Observable<any>> = [
				search.entries$,
				search.stats$,
				search.statsOverview$,
				search.statsZoom$,
				search.progress$,
				search.errors$,
			];
			for (const observable of observables) {
				observable.subscribe({
					complete: () => complete++,
				});
			}

			expect(complete).toBe(0);
			await search.close();
			expect(complete).toBe(observables.length);
		}),
		25000,
	);

	xit(
		'Should work with queries using the raw renderer w/ count module',
		integrationTest(async () => {
			// Create a macro to expand to "value" to test .query vs .effectiveQuery
			const macroName = uuidv4().toUpperCase();
			const createOneMacro = makeCreateOneMacro(TEST_BASE_API_CONTEXT);
			const deleteOneMacro = makeDeleteOneMacro(TEST_BASE_API_CONTEXT);
			const createdMacro = await createOneMacro({ name: macroName, expansion: 'value' });

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json $${macroName} | count`;
			//const effectiveQuery = `tag=${tag} json value | count`;
			const metadata = { test: 'abc' };

			const searchCreated = await subscribeToOneSearch(query, { metadata, filter: { dateRange: { start, end } } });
			const search = await attachToOneSearch(searchCreated.searchID);

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as TextSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const progressP = lastValueFrom(
				search.progress$.pipe(
					takeWhile(v => v < 100, true),
					toArray(),
				),
			);

			const statsP = lastValueFrom(search.stats$.pipe(takeWhile(s => !s.finished, true)));

			const [textEntries, progress, stats] = await Promise.all([textEntriesP, progressP, statsP]);

			////
			// Check stats
			////
			expect(stats.pipeline.length)
				.withContext('there should be two modules for this query: json and count')
				.toEqual(2);
			const [jsonModule, countModule] = stats.pipeline;

			expect(jsonModule.module).toEqual('json');
			expect(jsonModule.input.entries).withContext('json module should accept 100 entries of input').toEqual(count);
			expect(jsonModule.output.entries).withContext('json module should produce 100 entries of output').toEqual(count);

			expect(countModule.module).toEqual('count');
			expect(countModule.input.entries).withContext('count module should accept 100 entries of input').toEqual(count);
			expect(countModule.output.entries)
				.withContext('count module should produce 1 entry of output -- the count')
				.toEqual(1);

			expect(stats.metadata)
				.withContext('the search metadata should be present in the stats and unchanged')
				.toEqual(metadata);
			expect(stats.query).withContext(`Stats should contain the user query`).toBe(query);
			// TODO: Waiting on gravwell/gravwell#3677
			// expect(stats.effectiveQuery).withContext(`Stats should contain the effective query`).toBe(effectiveQuery);

			expect(stats.downloadFormats.sort())
				.withContext(`Download formats should include .json', .text', .csv' and .archive`)
				.toEqual(['archive', 'csv', 'json', 'text']);

			////
			// Check progress
			////
			if (progress.length > 1) {
				expect(progress[0].valueOf())
					.withContext('If more than one progress was emitted, the first should be 0')
					.toEqual(0);
			}
			expect(lastElt(progress)?.valueOf()).withContext('The last progress emitted should be 100%').toEqual(100);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('There should be only one entry, since we used the count module')
				.toEqual(1);
			const lastEntry = textEntries.data[0];
			expect(lastEntry).toBeDefined();
			expect(base64.decode(lastEntry.data))
				.withContext('The total count of entries should equal what we ingested')
				.toEqual(`count ${count}`);

			await deleteOneMacro(createdMacro.id);
		}),
		25000,
	);

	it(
		'Should work with queries using the raw renderer',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchCreated = await subscribeToOneSearch(query, { filter });
			const search = await attachToOneSearch(searchCreated.searchID, { filter });

			const textEntriesP = lastValueFrom(
				search.entries$.pipe(
					map(e => e as RawSearchEntries),
					takeWhile(e => !e.finished, true),
				),
			);

			const statsP = lastValueFrom(
				search.stats$.pipe(
					takeWhile(e => !e.finished, true),
					toArray(),
				),
			);

			const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
				textEntriesP,
				statsP,
				firstValueFrom(search.statsOverview$),
				firstValueFrom(search.statsZoom$),
			]);

			////
			// Check entries
			////
			expect(textEntries.data.length)
				.withContext('The number of entries should equal the total ingested')
				.toEqual(count);

			if (isUndefined(textEntries.filter) === false) {
				expect(textEntries.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
				if (isUndefined(entry) || isUndefined(original)) {
					fail('Exptected all entries and original data to be defined');
					return;
				}

				const value: Entry = JSON.parse(base64.decode(entry.data));
				const enumeratedValues = entry.values;
				const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
				const _value = enumeratedValues.find(v => v.name === 'value')!;

				expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
					isEnumerated: true,
					name: 'timestamp',
					value: original.timestamp,
				});

				expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
					isEnumerated: true,
					name: 'value',
					value: original.value.toString(),
				});

				expect(value.value)
					.withContext('Each value should match its index, descending')
					.toEqual(count - index - 1);
			});

			////
			// Check stats
			////
			expect(stats.length).toBeGreaterThan(0);

			if (isUndefined(stats[0].filter) === false) {
				expect(stats[0].filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			if (isUndefined(statsZoom.filter) === false) {
				expect(statsZoom.filter)
					.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
					.toPartiallyEqual(filter);
			}
			expect(stats[0].tags).withContext('Tag should match tag from query').toEqual([tag]);

			expect(sum(statsOverview.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsOverview should equal the total count ingested')
				.toEqual(count);
			expect(sum(statsZoom.frequencyStats.map(x => x.count)))
				.withContext('The sum of counts from statsZoom should equal the total count ingested')
				.toEqual(count);
		}),
		25000,
	);

	it(
		'Should treat multiple searches with the same query independently',
		integrationTest(async () => {
			// Number of multiple searches to create at the same time
			const SEARCHES_N = 4;

			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const query = `tag=${tag} json value timestamp | raw`;
			const filter: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

			const searchesCreated = await Promise.all(
				Array.from({ length: SEARCHES_N }).map(() => subscribeToOneSearch(query, { filter })),
			);
			const searches = await Promise.all(
				searchesCreated.map(searchCreated => attachToOneSearch(searchCreated.searchID, { filter })),
			);

			// Concat first because .reverse modifies the array
			const reversedData = originalData.concat().reverse();

			const testsP = searches.map(async (search, i) => {
				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length)
					.withContext('The number of entries should equal the total ingested')
					.toEqual(count);

				if (isUndefined(textEntries.filter) === false) {
					expect(textEntries.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				zip(textEntries.data, reversedData).forEach(([entry, original], index) => {
					if (isUndefined(entry) || isUndefined(original)) {
						fail('Exptected all entries and original data to be defined');
						return;
					}

					const value: Entry = JSON.parse(base64.decode(entry.data));
					const enumeratedValues = entry.values;
					const _timestamp = enumeratedValues.find(v => v.name === 'timestamp')!;
					const _value = enumeratedValues.find(v => v.name === 'value')!;

					expect(_timestamp).withContext(`Each entry should have an enumerated value called "timestamp"`).toEqual({
						isEnumerated: true,
						name: 'timestamp',
						value: original.timestamp,
					});

					expect(_value).withContext(`Each entry should have an enumerated value called "value"`).toEqual({
						isEnumerated: true,
						name: 'value',
						value: original.value.toString(),
					});

					expect(value.value)
						.withContext('Each value should match its index, descending')
						.toEqual(count - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).toBeGreaterThan(0);

				if (isUndefined(stats[0].filter) === false) {
					expect(stats[0].filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
			});

			await Promise.all(testsP);
		}),
		25000,
	);

	it(
		'Should reject on an inexistent search ID',
		integrationTest(async () => {
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);
			const searchID = `4723947892379482378`;
			await expectAsync(attachToOneSearch(searchID)).toBeRejected();
		}),
		25000,
	);

	it(
		'Should reject searches with invalid search IDs without affecting good ones',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const goodSearchID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;
			const badSearchID = `4723947892379482378`;

			// Attach to a bunch of search subscriptions with different search IDs to race them
			await Promise.all([
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(goodSearchID)).withContext('valid search ID should resolve').toBeResolved(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
				expectAsync(attachToOneSearch(badSearchID)).withContext('invalid search ID should reject').toBeRejected(),
			]);
		}),
		25000,
	);

	it(
		'Should work with several searches initiated simultaneously',
		integrationTest(async () => {
			const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
			const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

			const searchCreatedID = (await subscribeToOneSearch(`tag=${tag}`)).searchID;

			// Attach to a bunch of search subscriptions to race them
			await Promise.all(
				rangeLeft(0, 20).map(x =>
					expectAsync(attachToOneSearch(searchCreatedID)).withContext('good query should resolve').toBeResolved(),
				),
			);
		}),
		25000,
	);

	describe('stats', () => {
		it(
			'Should be evenly spread over a window matching the zoom/overview granularity',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const minutes = 90;
				const dateRange = { start, end: addMinutes(start, minutes) };

				const searchCreated = await subscribeToOneSearch(query, { filter: { dateRange } });
				const search = await attachToOneSearch(searchCreated.searchID);

				const textEntriesP = lastValueFrom(
					search.entries$.pipe(
						map(e => e as RawSearchEntries),
						takeWhile(e => !e.finished, true),
					),
				);

				const statsP = lastValueFrom(
					search.stats$.pipe(
						takeWhile(e => !e?.finished, true),
						toArray(),
					),
				);

				const [textEntries, stats, statsOverview, statsZoom] = await Promise.all([
					textEntriesP,
					statsP,
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				////
				// Check entries
				////
				expect(textEntries.data.length).withContext("Should be 90 entries since it's a 90 minute window").toEqual(90);
				textEntries.data.forEach((entry, index) => {
					const value: Entry = JSON.parse(base64.decode(entry.data));
					expect(value.value).toEqual(minutes - index - 1);
				});

				////
				// Check stats
				////
				expect(stats.length).withContext('expect to receive >0 stats from the stats observable').toBeGreaterThan(0);
				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsOverview should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsOverview.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsOverview element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext(
						'The sum of counts from statsZoom should equal the number of minutes -- 90 entries over 90 minutes',
					)
					.toEqual(minutes);
				expect(statsZoom.frequencyStats.every(x => x.count == 1))
					.withContext('Every statsZoom element should be 1 -- 90 entries over 90 minutes')
					.toBeTrue();
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 640;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than the total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter: SearchFilter = { entriesOffset: { index: 0, count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter });
				const search = await attachToOneSearch(searchCreated.searchID, { filter });

				await expectStatsFilter(search.stats$, filter);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter);
				}

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes
				const filter2: SearchFilter = { dateRange: { start, end: subMinutes(end, delta) } };
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter, ...filter2 });
				}
			}),
			25000,
		);

		it(
			'Should provide the minimum zoom window',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const dateRange = { start, end };

				// Issue a query where the minzoomwindow is predictable (1 second)
				const query1s = `tag=${tag} json value | stats mean(value) over 1s`;
				const filter1s: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange };

				const search1sCreated = await subscribeToOneSearch(query1s, { filter: filter1s });
				const search1s = await attachToOneSearch(search1sCreated.searchID, { filter: filter1s });

				const stats1s = await lastValueFrom(search1s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats1s.minZoomWindow).toEqual(1);
				if (isUndefined(stats1s.filter) === false) {
					expect(stats1s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1s);
				}

				// Issue a query where the minzoomwindow is predictable (33 seconds, why not)
				const query33s = `tag=${tag} json value | stats mean(value) over 33s`;
				const filter33s = { entriesOffset: { index: 0, count: count }, dateRange };

				const search33sCreated = await subscribeToOneSearch(query33s, { filter: filter33s });
				const search33s = await attachToOneSearch(search33sCreated.searchID, { filter: filter33s });

				const stats33s = await lastValueFrom(search33s.stats$.pipe(takeWhile(e => !e.finished, true)));

				expect(stats33s.minZoomWindow).toEqual(33);
				if (isUndefined(stats33s.filter) === false) {
					expect(stats33s.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter33s);
				}
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust when the zoom window adjusts with a different granularity for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const filter1: SearchFilter = { entriesOffset: { index: 0, count: count }, dateRange: { start, end } };

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				// the default
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using new granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(90);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for nicely-aligned bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that lines up nicely with the minZoomWindow buckets.
				// The timeframe of the query is wide enough that we get a minZoomWindow > 1, which makes assertions tricky without
				// this compensation.
				const delta = 468;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be "delta" less than total count ingested')
					.toEqual(count - delta + 1); // Account for inclusive end
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should use the new granularity')
					.toEqual(newZoomGranularity);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should adjust zoom granularity and overview granularity independently for odd bins',
			integrationTest(async () => {
				const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
				const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

				const query = `tag=${tag}`;
				const overviewGranularity = 133;
				const filter1: SearchFilter = {
					entriesOffset: { index: 0, count: count },
					overviewGranularity,
					dateRange: { start, end },
				};

				const searchCreated = await subscribeToOneSearch(query, { filter: filter1 });
				const search = await attachToOneSearch(searchCreated.searchID, { filter: filter1 });

				await expectStatsFilter(search.stats$, filter1);

				let [statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should equal the total count ingested')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should equal the total count ingested')
					.toEqual(count);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual(filter1);
				}

				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(overviewGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should start with the default granularity')
					.toEqual(90);

				// Choose a delta that doesn't line up nicely with the minZoomWindow buckets.
				const delta = 500;

				// Narrow the search window by moving the end date sooner by delta minutes using a new zoom granularity
				const newZoomGranularity = 133;
				const filter2: SearchFilter = {
					dateRange: { start, end: subMinutes(end, delta) },
					zoomGranularity: newZoomGranularity,
				};
				search.setFilter(filter2);

				await expectStatsFilter(search.stats$, filter2);

				[statsOverview, statsZoom] = await Promise.all([
					firstValueFrom(search.statsOverview$),
					firstValueFrom(search.statsZoom$),
				]);

				expect(sum(statsOverview.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsOverview should stay the same (total ingested)')
					.toEqual(count);
				expect(sum(statsZoom.frequencyStats.map(x => x.count)))
					.withContext('The sum of counts from statsZoom should be at least "count - delta"')
					.toBeGreaterThanOrEqual(count - delta);
				if (isUndefined(statsZoom.filter) === false) {
					expect(statsZoom.filter)
						.withContext(`The filter should be equal to the one used, plus the default values for undefined properties`)
						.toPartiallyEqual({ ...filter1, ...filter2 });
				}

				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be less than or equal to the new granularity')
					.toBeLessThanOrEqual(newZoomGranularity);
				expect(statsZoom.frequencyStats.length)
					.withContext('statsZoom should be close to the new granularity')
					.toBeGreaterThanOrEqual(newZoomGranularity - 2);
				expect(statsOverview.frequencyStats.length)
					.withContext('statsZoom should use the default granularity')
					.toEqual(overviewGranularity);
			}),
			25000,
		);

		it(
			'Should keep the dateRange when update the filter multiple times',
			integrationTest(
				makeKeepDataRangeTest({
					start,
					end,
					count,
					createSearch: async (initialFilter: SearchFilter): Promise<SearchSubscription> => {
						const subscribeToOneSearch = makeSubscribeToOneSearch(TEST_BASE_API_CONTEXT);
						const attachToOneSearch = makeAttachToOneSearch(TEST_BASE_API_CONTEXT);

						const query = `tag=*`;
						const searchCreated = await subscribeToOneSearch(query, { filter: initialFilter });
						return await attachToOneSearch(searchCreated.searchID, { filter: initialFilter });
					},
				}),
			),
			25000,
		);
	});
});
Example #23
Source File: manage-vault.tsx    From arkadiko with GNU General Public License v3.0 4 votes vote down vote up
ManageVault = ({ match }) => {
  const { doContractCall } = useConnect();
  const senderAddress = useSTXAddress();
  const [state, setState] = useContext(AppContext);
  const contractAddress = process.env.REACT_APP_CONTRACT_ADDRESS || '';

  const [showDepositModal, setShowDepositModal] = useState(false);
  const [showWithdrawModal, setShowWithdrawModal] = useState(false);
  const [showMintModal, setShowMintModal] = useState(false);
  const [showBurnModal, setShowBurnModal] = useState(false);
  const [showCloseModal, setShowCloseModal] = useState(false);
  const [auctionEnded, setAuctionEnded] = useState(false);

  const [maximumCollateralToWithdraw, setMaximumCollateralToWithdraw] = useState(0);
  const [reserveName, setReserveName] = useState('');
  const [vault, setVault] = useState<VaultProps>();
  const [price, setPrice] = useState(0);
  const [debtRatio, setDebtRatio] = useState(0);
  const [collateralType, setCollateralType] = useState<CollateralTypeProps>();
  const [isVaultOwner, setIsVaultOwner] = useState(false);
  const [stabilityFee, setStabilityFee] = useState(0);
  const [totalDebt, setTotalDebt] = useState(0);
  const [unlockBurnHeight, setUnlockBurnHeight] = useState(0);
  const [enabledStacking, setEnabledStacking] = useState(true);
  const [startedStacking, setStartedStacking] = useState(true);
  const [canWithdrawCollateral, setCanWithdrawCollateral] = useState(false);
  const [canUnlockCollateral, setCanUnlockCollateral] = useState(false);
  const [canStackCollateral, setCanStackCollateral] = useState(false);
  const [decimals, setDecimals] = useState(1000000);
  const [stackingEndDate, setStackingEndDate] = useState('');
  const [poxYield, setPoxYield] = useState(0);
  const [usdaYield, setUsdaYield] = useState(0);
  const [burnBlockHeight, setBurnBlockHeight] = useState(0);

  const [loadingVaultData, setLoadingVaultData] = useState(true);
  const [loadingFeesData, setLoadingFeesData] = useState(true);
  const [loadingStackerData, setLoadingStackerData] = useState(true);
  const [loadingPoxYieldData, setLoadingPoxYieldData] = useState(true);

  useEffect(() => {
    const fetchVault = async () => {
      const serializedVault = await callReadOnlyFunction({
        contractAddress,
        contractName: 'arkadiko-freddie-v1-1',
        functionName: 'get-vault-by-id',
        functionArgs: [uintCV(match.params.id)],
        senderAddress: senderAddress || contractAddress,
        network: network,
      });

      const data = cvToJSON(serializedVault).value;

      if (data['id'].value !== 0) {
        setVault({
          id: data['id'].value,
          owner: data['owner'].value,
          collateral: data['collateral'].value,
          collateralType: data['collateral-type'].value,
          collateralToken: data['collateral-token'].value,
          isLiquidated: data['is-liquidated'].value,
          auctionEnded: data['auction-ended'].value,
          leftoverCollateral: data['leftover-collateral'].value,
          debt: data['debt'].value,
          stackedTokens: data['stacked-tokens'].value,
          stackerName: data['stacker-name'].value,
          revokedStacking: data['revoked-stacking'].value,
          collateralData: {},
        });
        setReserveName(resolveReserveName(data['collateral-token'].value));
        setAuctionEnded(data['auction-ended'].value);
        setIsVaultOwner(data['owner'].value === senderAddress);

        const price = await getPrice(data['collateral-token'].value);
        setPrice(price);

        const type = await callReadOnlyFunction({
          contractAddress,
          contractName: 'arkadiko-collateral-types-v1-1',
          functionName: 'get-collateral-type-by-name',
          functionArgs: [stringAsciiCV(data['collateral-type'].value)],
          senderAddress: senderAddress || contractAddress,
          network: network,
        });

        const json = cvToJSON(type.value);
        setCollateralType({
          name: json.value['name'].value,
          token: json.value['token'].value,
          tokenType: json.value['token-type'].value,
          url: json.value['url'].value,
          totalDebt: json.value['total-debt'].value,
          collateralToDebtRatio: json.value['collateral-to-debt-ratio'].value,
          liquidationPenalty: json.value['liquidation-penalty'].value / 100,
          liquidationRatio: json.value['liquidation-ratio'].value,
          maximumDebt: json.value['maximum-debt'].value,
          stabilityFee: json.value['stability-fee'].value,
          stabilityFeeApy: json.value['stability-fee-apy'].value,
        });
        setLoadingVaultData(false);
      }
    };
    fetchVault();
  }, [match.params.id]);

  useEffect(() => {
    if (vault && collateralType?.collateralToDebtRatio) {
      if (Number(vault.stackedTokens) === 0) {
        setMaximumCollateralToWithdraw(
          availableCollateralToWithdraw(
            price,
            collateralLocked(),
            outstandingDebt(),
            collateralType?.collateralToDebtRatio,
            vault?.collateralToken
          )
        );
      } else {
        setMaximumCollateralToWithdraw(0);
      }
    }
  }, [collateralType?.collateralToDebtRatio, price]);

  useEffect(() => {
    const fetchStackingInfo = async () => {
      const client = getRPCClient();
      const response = await fetch(`${client.url}/v2/info`, { credentials: 'omit' });
      const data = await response.json();
      setBurnBlockHeight(data['burn_block_height']);
    };

    const fetchFees = async () => {
      const feeCall = await callReadOnlyFunction({
        contractAddress,
        contractName: 'arkadiko-freddie-v1-1',
        functionName: 'get-stability-fee-for-vault',
        functionArgs: [
          uintCV(vault?.id),
          contractPrincipalCV(contractAddress || '', 'arkadiko-collateral-types-v1-1'),
        ],
        senderAddress: contractAddress || '',
        network: network,
      });

      const fee = cvToJSON(feeCall);
      setStabilityFee(fee.value.value);
      setTotalDebt(outstandingDebt() + fee.value.value / 1000000);
      setLoadingFeesData(false);
    };

    const fetchYield = async () => {
      const yieldCall = await callReadOnlyFunction({
        contractAddress,
        contractName: 'arkadiko-claim-yield-v2-1',
        functionName: 'get-claim-by-vault-id',
        functionArgs: [uintCV(vault?.id)],
        senderAddress: contractAddress || '',
        network: network,
      });
      const poxYield = cvToJSON(yieldCall);
      setPoxYield(poxYield.value['ustx'].value / 1000000);

      const usdaYieldCall = await callReadOnlyFunction({
        contractAddress,
        contractName: 'arkadiko-claim-usda-yield-v2-1',
        functionName: 'get-claim-by-vault-id',
        functionArgs: [uintCV(vault?.id)],
        senderAddress: contractAddress || '',
        network: network,
      });
      const poxUsdaYield = cvToJSON(usdaYieldCall);
      setUsdaYield(poxUsdaYield.value['usda'].value / 1000000);

      setLoadingPoxYieldData(false);
    };

    const fetchCollateralToDebtRatio = async () => {
      if (vault && vault['debt'] > 0) {
        const collToDebt = await callReadOnlyFunction({
          contractAddress,
          contractName: 'arkadiko-freddie-v1-1',
          functionName: 'calculate-current-collateral-to-debt-ratio',
          functionArgs: [
            uintCV(vault.id),
            contractPrincipalCV(contractAddress || '', 'arkadiko-collateral-types-v1-1'),
            contractPrincipalCV(contractAddress || '', 'arkadiko-oracle-v1-1'),
            falseCV(),
          ],
          senderAddress: senderAddress || contractAddress,
          network: network,
        });
        const json = cvToJSON(collToDebt);
        if (json.value) {
          setDebtRatio(json.value.value);
        }
      }
    };

    const fetchStackerHeight = async () => {
      if (vault?.stackedTokens == 0 && vault?.revokedStacking) {
        setEnabledStacking(false);
      }

      const name = vault?.stackerName;
      let contractName = 'arkadiko-stacker-v1-1';
      if (name === 'stacker-2') {
        contractName = 'arkadiko-stacker-2-v1-1';
      } else if (name === 'stacker-3') {
        contractName = 'arkadiko-stacker-3-v1-1';
      } else if (name === 'stacker-4') {
        contractName = 'arkadiko-stacker-4-v1-1';
      }

      const call = await callReadOnlyFunction({
        contractAddress,
        contractName,
        functionName: 'get-stacking-unlock-burn-height',
        functionArgs: [],
        senderAddress: contractAddress || '',
        network: network,
      });
      const unlockBurnHeight = cvToJSON(call).value.value;
      setUnlockBurnHeight(unlockBurnHeight);
      const client = getRPCClient();
      const response = await fetch(`${client.url}/v2/info`, { credentials: 'omit' });
      const data = await response.json();
      const currentBurnHeight = data['burn_block_height'];

      if (Number(unlockBurnHeight) === 0) {
        setStartedStacking(false);
        if (Number(vault?.stackedTokens) === 0) {
          setCanWithdrawCollateral(true);
        }
        setCanUnlockCollateral(true);
        setLoadingStackerData(false);
        return;
      } else {
        setStartedStacking(true);
        if (unlockBurnHeight < currentBurnHeight) {
          setCanUnlockCollateral(true);
        }
        if (Number(vault?.stackedTokens) === 0 || unlockBurnHeight < currentBurnHeight) {
          setCanWithdrawCollateral(true);
        } else {
          setCanWithdrawCollateral(false);
        }
      }

      if (unlockBurnHeight < currentBurnHeight) {
        setStackingEndDate('');
      } else {
        const stackingBlocksLeft = unlockBurnHeight - currentBurnHeight;
        const stackingMinutesLeft = stackingBlocksLeft * 10 + 1440; // stacking blocks left * 10 minutes/block + 1 day
        const currentDate = new Date();
        const endDate = addMinutes(currentDate, stackingMinutesLeft);
        setStackingEndDate(endDate.toDateString());
      }
      setLoadingStackerData(false);
    };

    if (vault?.id) {
      if (vault['collateralType'].toLowerCase().includes('stx')) {
        setCanStackCollateral(true);
        fetchYield();
      }
      setDecimals(vault['collateralType'].toLowerCase().includes('stx') ? 1000000 : 100000000);
      fetchFees();
      fetchStackerHeight();
      fetchCollateralToDebtRatio();
      fetchStackingInfo();
    }
  }, [vault]);

  useEffect(() => {
    if (state.currentTxStatus === 'success') {
      window.location.reload();
    }
  }, [state.currentTxStatus]);

  const liquidationPrice = () => {
    if (vault) {
      // (liquidationRatio * coinsMinted) / collateral = rekt
      return getLiquidationPrice(
        collateralType?.liquidationRatio,
        vault['debt'],
        vault['collateral'],
        vault['collateralToken']
      );
    }

    return 0;
  };

  const collateralLocked = () => {
    if (vault) {
      const decimals = vault['collateralType'].toLowerCase().includes('stx') ? 1000000 : 100000000;
      return vault['collateral'] / decimals;
    }

    return 0;
  };

  const outstandingDebt = () => {
    if (vault) {
      return vault.debt / 1000000;
    }

    return 0;
  };

  const callToggleStacking = async () => {
    await doContractCall({
      network,
      contractAddress,
      stxAddress: senderAddress,
      contractName: 'arkadiko-freddie-v1-1',
      functionName: 'toggle-stacking',
      functionArgs: [uintCV(match.params.id)],
      onFinish: data => {
        console.log('finished toggling stacking!', data, data.txId);
        setState(prevState => ({
          ...prevState,
          currentTxId: data.txId,
          currentTxStatus: 'pending',
        }));
      },
      anchorMode: AnchorMode.Any,
    });
  };

  const stackCollateral = async () => {
    await doContractCall({
      network,
      contractAddress,
      stxAddress: senderAddress,
      contractName: 'arkadiko-freddie-v1-1',
      functionName: 'stack-collateral',
      functionArgs: [uintCV(match.params.id)],
      onFinish: data => {
        console.log('finished stacking!', data, data.txId);
        setState(prevState => ({
          ...prevState,
          currentTxId: data.txId,
          currentTxStatus: 'pending',
        }));
      },
      anchorMode: AnchorMode.Any,
    });
  };

  const unlockCollateral = async () => {
    const name = vault?.stackerName;
    let stackerId = 1;
    if (name === 'stacker-2') {
      stackerId = 2;
    } else if (name === 'stacker-3') {
      stackerId = 3;
    } else if (name === 'stacker-4') {
      stackerId = 4;
    }

    await doContractCall({
      network,
      contractAddress,
      stxAddress: senderAddress,
      contractName: 'arkadiko-pox-unstack-unlock-v1-1',
      functionName: 'unstack-and-unlock',
      functionArgs: [uintCV(match.params.id), uintCV(stackerId)],
      onFinish: data => {
        setState(prevState => ({
          ...prevState,
          currentTxId: data.txId,
          currentTxStatus: 'pending',
        }));
      },
      anchorMode: AnchorMode.Any,
    });
  };

  const claimYield = async () => {
    await doContractCall({
      network,
      contractAddress,
      stxAddress: senderAddress,
      contractName: 'arkadiko-claim-yield-v2-1',
      functionName: 'claim',
      postConditionMode: 0x01,
      functionArgs: [
        uintCV(match.params.id),
        contractPrincipalCV(process.env.REACT_APP_CONTRACT_ADDRESS || '', reserveName),
        contractPrincipalCV(
          process.env.REACT_APP_CONTRACT_ADDRESS || '',
          'arkadiko-collateral-types-v1-1'
        ),
        falseCV(),
      ],
      onFinish: data => {
        console.log('claiming yield', data, data.txId);
        setState(prevState => ({
          ...prevState,
          currentTxId: data.txId,
          currentTxStatus: 'pending',
        }));
      },
      anchorMode: AnchorMode.Any,
    });
  };

  const claimYieldPayDebt = async () => {
    await doContractCall({
      network,
      contractAddress,
      stxAddress: senderAddress,
      contractName: 'arkadiko-claim-usda-yield-v2-1',
      functionName: 'claim-and-burn',
      postConditionMode: 0x01,
      functionArgs: [
        uintCV(match.params.id),
        contractPrincipalCV(process.env.REACT_APP_CONTRACT_ADDRESS || '', reserveName),
        contractPrincipalCV(
          process.env.REACT_APP_CONTRACT_ADDRESS || '',
          'arkadiko-collateral-types-v1-1'
        ),
      ],
      onFinish: data => {
        console.log('claiming yield', data, data.txId);
        setState(prevState => ({
          ...prevState,
          currentTxId: data.txId,
          currentTxStatus: 'pending',
        }));
      },
      anchorMode: AnchorMode.Any,
    });
  };

  return (
    <Container>
      {auctionEnded && <Redirect to="/vaults" />}

      <VaultDepositModal
        showDepositModal={showDepositModal}
        setShowDepositModal={setShowDepositModal}
        match={match}
        vault={vault}
        reserveName={reserveName}
        decimals={decimals}
      />

      <VaultWithdrawModal
        showWithdrawModal={showWithdrawModal}
        setShowWithdrawModal={setShowWithdrawModal}
        match={match}
        maximumCollateralToWithdraw={maximumCollateralToWithdraw}
        vault={vault}
        reserveName={reserveName}
      />

      <VaultMintModal
        showMintModal={showMintModal}
        setShowMintModal={setShowMintModal}
        match={match}
        vault={vault}
        reserveName={reserveName}
        price={price}
        collateralType={collateralType}
      />

      <VaultBurnModal
        showBurnModal={showBurnModal}
        setShowBurnModal={setShowBurnModal}
        outstandingDebt={outstandingDebt}
        stabilityFee={stabilityFee}
        match={match}
        vault={vault}
        reserveName={reserveName}
      />

      <VaultCloseModal
        showCloseModal={showCloseModal}
        setShowCloseModal={setShowCloseModal}
        match={match}
        vault={vault}
        reserveName={reserveName}
      />

      <main className="flex-1 py-12">
        <section>
          <header className="pb-5 border-b border-gray-200 dark:border-zinc-600">
            <div className="flex items-center justify-between">
              <h2 className="text-xl font-bold leading-6 text-gray-900 font-headings dark:text-zinc-50">
                {loadingVaultData ? (
                  <Placeholder className="py-2 w-[150px]" color={Placeholder.color.GRAY} />
                ) : (
                  <>
                    {vault?.collateralToken.toUpperCase()}/USDA — Vault #{match.params.id}
                  </>
                )}
              </h2>

              {debtRatio > 0 ? (
                loadingVaultData ? (
                  <Placeholder
                    className="justify-end py-2"
                    color={Placeholder.color.GRAY}
                    width={Placeholder.width.THIRD}
                  />
                ) : (
                  <>
                    {/* TODO: Make component out of this */}
                    {debtClass(collateralType?.liquidationRatio, debtRatio) == 'text-green-500' ? (
                      <span className="overflow-hidden group inline-flex items-center px-3 py-0.5 rounded-full text-sm font-semibold bg-green-100 text-green-800 h-6">
                        <StyledIcon as="ShieldCheckIcon" size={5} className="mr-2" />
                        Healthy
                        <span className="flex items-center flex-shrink-0 invisible w-0 h-0 group-hover:w-full group-hover:visible group-hover:h-6">
                          <svg
                            className="w-1.5 h-1.5 mx-1 flex-shrink-0"
                            fill="currentColor"
                            viewBox="0 0 8 8"
                          >
                            <circle cx={4} cy={4} r={3} />
                          </svg>
                          <span className="flex-shrink-0">Low liquidation risk</span>
                        </span>
                      </span>
                    ) : debtClass(collateralType?.liquidationRatio, debtRatio) ==
                      'text-orange-500' ? (
                      <span className="overflow-hidden group inline-flex items-center px-3 py-0.5 rounded-full text-sm font-semibold bg-yellow-100 text-yellow-800 h-6">
                        <StyledIcon as="ExclamationIcon" size={5} className="mr-2" />
                        Warning
                        <span className="flex items-center flex-shrink-0 invisible w-0 h-0 group-hover:w-full group-hover:visible group-hover:h-6">
                          <svg
                            className="w-1.5 h-1.5 mx-1 flex-shrink-0"
                            fill="currentColor"
                            viewBox="0 0 8 8"
                          >
                            <circle cx={4} cy={4} r={3} />
                          </svg>
                          <span className="flex-shrink-0">Medium liquidation risk</span>
                        </span>
                      </span>
                    ) : (
                      <span className="overflow-hidden group inline-flex items-center px-3 py-0.5 rounded-full text-sm font-semibold bg-red-100 text-red-800 h-6">
                        <StyledIcon as="ShieldExclamationIcon" size={5} className="mr-2" />
                        Danger
                        <span className="flex items-center flex-shrink-0 invisible w-0 h-0 group-hover:w-full group-hover:visible group-hover:h-6">
                          <svg
                            className="w-1.5 h-1.5 mx-1 flex-shrink-0"
                            fill="currentColor"
                            viewBox="0 0 8 8"
                          >
                            <circle cx={4} cy={4} r={3} />
                          </svg>
                          <span className="flex-shrink-0">High liquidation risk</span>
                        </span>
                      </span>
                    )}
                  </>
                )
              ) : null}
            </div>
          </header>

          <div className="mt-4" id="liquidation-status-alert">
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
              <div className="flex flex-col bg-white divide-y divide-gray-200 rounded-md shadow dark:bg-zinc-800 dark:divide-zinc-600">
                <div className="px-4 py-3">
                  <h3 className="text-base font-normal leading-6 text-gray-900 font-headings dark:text-zinc-50">
                    Vault details
                  </h3>
                </div>
                <div className="flex flex-col h-full px-4 py-3">
                  <dl>
                    <div className="sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                      <dt className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                        <p className="text-sm font-normal leading-6 text-gray-500 dark:text-zinc-400">
                          Collateral to Debt ratio
                        </p>
                        <Tooltip
                          shouldWrapChildren={true}
                          label={`The amount of collateral you deposit in a vault versus the stablecoin debt you are minting against it`}
                        >
                          <StyledIcon
                            as="InformationCircleIcon"
                            size={5}
                            className="block ml-2 text-gray-400"
                          />
                        </Tooltip>
                      </dt>
                      <dd className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                        {loadingVaultData ? (
                          <Placeholder
                            className="justify-end py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.FULL}
                          />
                        ) : (
                          <p
                            className={`text-base font-semibold leading-none ${debtClass(
                              collateralType?.liquidationRatio,
                              debtRatio
                            )}`}
                          >
                            {debtRatio}
                            <span className="text-sm font-normal">%</span>
                          </p>
                        )}
                      </dd>
                    </div>

                    <div className="mt-2 sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                      <dt className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                        <p className="text-sm font-normal leading-6 text-gray-500 dark:text-zinc-400">
                          Minimum Ratio (before liquidation)
                        </p>
                        <Tooltip
                          shouldWrapChildren={true}
                          label={`The collateral-to-debt ratio when your vault gets liquidated`}
                        >
                          <StyledIcon
                            as="InformationCircleIcon"
                            size={5}
                            className="block ml-2 text-gray-400"
                          />
                        </Tooltip>
                      </dt>
                      <dd className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                        {loadingVaultData ? (
                          <Placeholder
                            className="justify-end py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.FULL}
                          />
                        ) : (
                          <p className="text-lg font-semibold leading-none">
                            {collateralType?.liquidationRatio}
                            <span className="text-sm font-normal">%</span>
                          </p>
                        )}
                      </dd>
                    </div>

                    <div className="mt-2 sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                      <dt className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                        <p className="text-sm font-normal leading-6 text-gray-500 dark:text-zinc-400">
                          Liquidation penalty
                        </p>
                        <Tooltip
                          shouldWrapChildren={true}
                          label={`The penalty you pay when your vault gets liquidated`}
                        >
                          <StyledIcon
                            as="InformationCircleIcon"
                            size={5}
                            className="block ml-2 text-gray-400"
                          />
                        </Tooltip>
                      </dt>
                      <dd className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                        {loadingVaultData ? (
                          <Placeholder
                            className="justify-end py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.FULL}
                          />
                        ) : (
                          <p className="text-lg font-semibold leading-none">
                            {collateralType?.liquidationPenalty}
                            <span className="text-sm font-normal">%</span>
                          </p>
                        )}
                      </dd>
                    </div>
                  </dl>

                  <div className="p-3 mt-auto rounded-md bg-gray-50 dark:bg-gray-200">
                    <p className="text-xs font-semibold leading-none text-gray-400 uppercase dark:text-gray-500">
                      Current {vault?.collateralToken} price
                    </p>
                    <p className="mt-1 text-sm font-semibold text-gray-900">${price / 1000000}</p>
                  </div>
                </div>
              </div>
              <div className="sm:col-span-2">
                <div className="bg-white rounded-md shadow dark:bg-zinc-800">
                  <div className="flex flex-col px-4 py-5 sm:p-6">
                    <div>
                      <div className="flex items-start justify-between">
                        <div>
                          <p className="flex items-center text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                            Available to mint
                            <Tooltip
                              className="ml-2"
                              shouldWrapChildren={true}
                              label={`When the price of ${vault?.collateralToken.toUpperCase()} increases compared to when you created a vault, your collateral is bigger in dollar value so you can mint more.`}
                            >
                              <StyledIcon
                                as="InformationCircleIcon"
                                size={5}
                                className="block ml-2 text-gray-400"
                              />
                            </Tooltip>
                          </p>
                          {loadingVaultData ? (
                            <Placeholder
                              className="py-2"
                              color={Placeholder.color.INDIGO}
                              width={Placeholder.width.THIRD}
                            />
                          ) : (
                            <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                              {availableCoinsToMint(
                                price,
                                collateralLocked(),
                                outstandingDebt(),
                                collateralType?.collateralToDebtRatio
                              ).toLocaleString(undefined, {
                                minimumFractionDigits: 2,
                                maximumFractionDigits: 6,
                              })}{' '}
                              <span className="text-sm font-normal">USDA</span>
                            </p>
                          )}
                        </div>
                        {isVaultOwner &&
                        !loadingVaultData &&
                        Number(
                          availableCoinsToMint(
                            price,
                            collateralLocked(),
                            outstandingDebt(),
                            collateralType?.collateralToDebtRatio
                          )
                        ) > 0 ? (
                          <button
                            type="button"
                            className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                            onClick={() => setShowMintModal(true)}
                          >
                            Mint
                          </button>
                        ) : null}
                      </div>

                      <div className="mt-4">
                        <div className="flex items-start justify-between">
                          <div>
                            <p className="flex items-center text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                              Outstanding USDA debt
                              <Tooltip
                                className="ml-2"
                                shouldWrapChildren={true}
                                label={`Includes a ${
                                  collateralType?.stabilityFeeApy / 100
                                }% yearly stability fee.`}
                              >
                                <StyledIcon
                                  as="InformationCircleIcon"
                                  size={5}
                                  className="block ml-2 text-gray-400"
                                />
                              </Tooltip>
                            </p>
                            {loadingFeesData || loadingVaultData ? (
                              <Placeholder
                                className="py-2"
                                color={Placeholder.color.INDIGO}
                                width={Placeholder.width.THIRD}
                              />
                            ) : (
                              <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                                {totalDebt.toLocaleString(undefined, {
                                  minimumFractionDigits: 2,
                                  maximumFractionDigits: 6,
                                })}{' '}
                                <span className="text-sm font-normal">USDA</span>
                              </p>
                            )}
                          </div>
                          {!loadingStackerData &&
                          isVaultOwner &&
                          canWithdrawCollateral &&
                          Number(vault?.stackedTokens) === 0 &&
                          Number(totalDebt) <= 0.6 ? (
                            <button
                              type="button"
                              className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                              onClick={() => setShowCloseModal(true)}
                            >
                              Withdraw Collateral & Close Vault
                            </button>
                          ) : !loadingStackerData && isVaultOwner ? (
                            <button
                              type="button"
                              className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                              onClick={() => setShowBurnModal(true)}
                            >
                              Pay back
                            </button>
                          ) : loadingStackerData ? (
                            <Placeholder
                              className="justify-end py-2"
                              color={Placeholder.color.INDIGO}
                              width={Placeholder.width.THIRD}
                            />
                          ) : null}
                        </div>
                      </div>
                    </div>

                    {debtRatio > 0 ? (
                      <div className="mt-6">
                        <div>
                          {loadingVaultData ? (
                            <div className="p-4 border-l-4 border-gray-400 rounded-tr-md rounded-br-md bg-gray-50 dark:bg-gray-200">
                              <div className="flex">
                                <div className="w-5 h-5 bg-gray-300 rounded-full shrink-0" />
                                <div className="flex-1 ml-3">
                                  <Placeholder
                                    className="py-2"
                                    color={Placeholder.color.GRAY}
                                    width={Placeholder.width.HALF}
                                  />
                                  <Placeholder
                                    className="py-2"
                                    color={Placeholder.color.GRAY}
                                    width={Placeholder.width.THIRD}
                                  />
                                  <Placeholder
                                    className="py-2"
                                    color={Placeholder.color.GRAY}
                                    width={Placeholder.width.FULL}
                                  />
                                </div>
                              </div>
                            </div>
                          ) : (
                            <>
                              {/* TODO: make component out of this */}
                              {debtClass(collateralType?.liquidationRatio, debtRatio) ==
                              'text-green-500' ? (
                                <Alert type={Alert.type.SUCCESS} title="Low liquidation risk">
                                  <p>
                                    Good job! Your vault looks quite healthy. Your liquidation price
                                    ({vault?.collateralToken} below{' '}
                                    <span className="font-semibold">${liquidationPrice()}</span>) is
                                    still very far but keep in mind that you can pay back the
                                    outstanding debt or deposit extra collateral at any time anyway.
                                  </p>
                                </Alert>
                              ) : debtClass(collateralType?.liquidationRatio, debtRatio) ==
                                'text-orange-500' ? (
                                <Alert type={Alert.type.WARNING} title="Medium liquidation risk">
                                  <p>
                                    Be careful. You will be liquidated if the{' '}
                                    {vault?.collateralToken} price drops below{' '}
                                    <span className="font-semibold">${liquidationPrice()} USD</span>
                                    . Pay back the outstanding debt or deposit extra collateral to
                                    keep your vault healthy.
                                  </p>
                                </Alert>
                              ) : (
                                <Alert type={Alert.type.ERROR} title="High liquidation risk">
                                  <p>
                                    You are very close to being liquidated. It will happen if the{' '}
                                    {vault?.collateralToken} price drops below{' '}
                                    <span className="font-semibold">${liquidationPrice()} USD</span>
                                    . Pay back the outstanding debt or deposit extra collateral to
                                    keep your vault healthy.
                                  </p>
                                </Alert>
                              )}
                            </>
                          )}
                        </div>
                      </div>
                    ) : null}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>

        <section className="mt-12">
          <header className="pb-5 border-b border-gray-200 dark:border-zinc-600">
            <div className="flex items-center justify-between">
              <div className="flex items-center">
                <h3 className="text-lg font-bold leading-6 text-gray-900 font-headings dark:text-zinc-50">
                  {canStackCollateral ? `Stacking` : `Manage`}
                </h3>

                {canStackCollateral && !loadingVaultData ? (
                  enabledStacking ? (
                    <span className="ml-3 inline-flex items-center px-3 py-0.5 rounded-full text-sm font-semibold bg-green-100 text-green-800">
                      <StyledIcon as="CheckCircleIcon" size={5} className="mr-2" />
                      Enabled
                    </span>
                  ) : (
                    <span className="ml-3 inline-flex items-center px-3 py-0.5 rounded-full text-sm font-semibold bg-red-100 text-red-800">
                      <StyledIcon as="XCircleIcon" size={5} className="mr-2" />
                      Disabled
                    </span>
                  )
                ) : null}
              </div>

              {canStackCollateral ? (
                <div className="flex items-start justify-between">
                  <div>
                    {canStackCollateral &&
                    isVaultOwner &&
                    vault?.stackedTokens > 0 &&
                    !vault?.revokedStacking &&
                    !startedStacking &&
                    !loadingVaultData ? (
                      // cycle not started, offer to opt-out
                      <button
                        type="button"
                        className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                        onClick={() => callToggleStacking()}
                      >
                        Unstack
                      </button>
                    ) : canStackCollateral &&
                      isVaultOwner &&
                      vault?.stackedTokens > 0 &&
                      vault?.revokedStacking &&
                      !loadingVaultData ? (
                      // user has unstacked collateral, offer to stack again
                      <button
                        type="button"
                        className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                        onClick={() => callToggleStacking()}
                      >
                        Restack
                      </button>
                    ) : canStackCollateral &&
                      vault?.stackedTokens == 0 &&
                      isVaultOwner &&
                      !loadingVaultData ? (
                      // user is not stacking
                      <button
                        type="button"
                        className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                        onClick={() => stackCollateral()}
                      >
                        Stack
                      </button>
                    ) : null}
                  </div>
                </div>
              ) : null}
            </div>
          </header>

          <div className="mt-4">
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
              <section>
                {canStackCollateral ? (
                  <div className="relative">
                    <div
                      className="absolute w-full h-full dark:opacity-30"
                      style={{
                        backgroundImage: 'url(/assets/stacks-pattern.png)',
                        backgroundSize: '20%',
                      }}
                    />
                    <a
                      className="absolute top-0 right-0 z-10 mt-2 mr-2 bg-indigo-600 rounded-full"
                      href="https://stacking.club/"
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <svg className="w-8 h-8" viewBox="0 0 120 120" aria-hidden="true">
                        <circle cx="60" cy="60" r="60" fill="#5546FF" />
                        <path
                          d="M86.0779 92L72.4991 71.4267H91.9992V63.6647H28V71.4357H47.4937L33.9214 92H44.0471L59.9997 67.8295L75.9522 92H86.0779ZM91.9993 56.1313V48.2912H72.8952L86.2874 28H76.1618L59.9995 52.4877L43.8375 28H33.7119L47.1212 48.3094H28V56.1313H91.9993Z"
                          fill="white"
                        />
                      </svg>
                    </a>

                    <dl className="relative grid grid-cols-1 overflow-hidden bg-indigo-100 bg-opacity-50 border border-indigo-200 divide-y divide-indigo-200 rounded-lg shadow-sm dark:bg-zinc-700 dark:bg-opacity-95 dark:border-zinc-600 dark:divide-zinc-600">
                      <div className="px-4 py-3">
                        <dt className="text-xs font-semibold text-indigo-600 uppercase dark:text-indigo-100">
                          Stacking Cycle #
                        </dt>
                        <dd className="flex items-baseline justify-between mt-1 md:block">
                          <div className="flex items-baseline justify-between flex-1 text-lg font-semibold text-indigo-800 dark:text-indigo-200">
                            {state.cycleNumber}
                          </div>
                        </dd>
                      </div>
                      <div className="px-4 py-3">
                        <dt className="text-xs font-semibold text-indigo-600 uppercase dark:text-indigo-100">
                          Days in cycle
                        </dt>
                        <dd className="flex items-baseline justify-between mt-1 md:block">
                          <div className="flex items-baseline justify-between flex-1 text-lg font-semibold text-indigo-800 dark:text-indigo-200">
                            <span>
                              {state.daysPassed}{' '}
                              <span className="text-xs opacity-80">(since {state.startDate})</span>
                            </span>
                            <a
                              className="hover:underline"
                              href={`https://mempool.space/block/${state.cycleStartHeight}`}
                              target="_blank"
                              rel="noopener noreferrer"
                            >
                              <span className="text-xs">~#{state.cycleStartHeight}</span>
                            </a>
                          </div>
                        </dd>
                      </div>
                      <div className="px-4 py-3">
                        <dt className="text-xs font-semibold text-indigo-600 uppercase dark:text-indigo-100">
                          Days left
                        </dt>
                        <dd className="flex items-baseline justify-between mt-1 md:block">
                          <div className="flex items-baseline justify-between flex-1 text-lg font-semibold text-indigo-800 dark:text-indigo-200">
                            <span>
                              {state.daysLeft}{' '}
                              <span className="text-xs opacity-80">(ends on {state.endDate})</span>
                            </span>
                            <a
                              className="hover:underline"
                              href="https://mempool.space/"
                              target="_blank"
                              rel="noopener noreferrer"
                            >
                              <span className="text-xs">~#{state.cycleEndHeight}</span>
                            </a>
                          </div>
                        </dd>
                      </div>
                    </dl>
                  </div>
                ) : null}
                <div className={canStackCollateral ? `mt-3` : ``}>
                  <dl className="relative border border-gray-300 divide-y rounded-lg shadow-sm bg-zinc-200/30 dark:bg-gray-500 dark:border-gray-700">
                    <div className="px-4 py-3">
                      <dt className="text-xs font-semibold text-gray-500 uppercase dark:text-gray-300">
                        Current Bitcoin block height
                      </dt>
                      <dd className="flex items-baseline justify-between mt-1 md:block lg:flex">
                        <div className="justify-between font-semibold text-gray-600 dark:text-gray-50">
                          <a
                            className="hover:underline"
                            href="https://mempool.space/"
                            target="_blank"
                            rel="noopener noreferrer"
                          >
                            #{burnBlockHeight}
                          </a>
                        </div>
                      </dd>
                    </div>
                    <a
                      className="absolute top-0 right-0 z-10 mt-2 mr-2 rounded-full"
                      href="https://mempool.space/"
                      target="_blank"
                      rel="noopener noreferrer"
                    >
                      <svg
                        className="w-8 h-8 filter grayscale"
                        viewBox="0 0 120 120"
                        aria-hidden="true"
                      >
                        <path
                          fill="#F7931A"
                          d="M118.2 74.52a59.98 59.98 0 0 1-72.72 43.68 59.98 59.98 0 1 1 72.72-43.68Z"
                        />
                        <path
                          fill="#fff"
                          d="M86.46 51.45c1.2-7.98-4.89-12.27-13.2-15.14l2.7-10.81-6.59-1.64-2.63 10.53c-1.73-.43-3.5-.84-5.27-1.24l2.64-10.6-6.58-1.64-2.69 10.8-13.28-3.28-1.75 7.03s4.88 1.12 4.78 1.19c2.67.67 3.15 2.43 3.07 3.83L40.27 70.1c-.33.8-1.15 2.02-3.02 1.56.07.1-4.78-1.2-4.78-1.2L29.2 78l13.26 3.35-2.73 10.94 6.58 1.64 2.7-10.82c1.8.49 3.54.94 5.25 1.36l-2.7 10.77 6.59 1.64 2.73-10.92C72.1 88.1 80.55 87.24 84.1 77.1c2.86-8.17-.14-12.89-6.05-15.96 4.3-1 7.54-3.83 8.4-9.67h.01Zm-15.05 21.1c-2.04 8.17-15.8 3.75-20.26 2.64l3.61-14.49c4.46 1.11 18.77 3.32 16.65 11.85Zm2.04-21.21C71.6 58.78 60.14 55 56.42 54.07l3.27-13.15c3.72.93 15.7 2.66 13.76 10.42"
                        />
                      </svg>
                    </a>
                  </dl>
                </div>
              </section>
              <div className="sm:col-span-2">
                <div className="bg-white rounded-md shadow dark:bg-zinc-800">
                  <div className="px-4 py-5 sm:p-6">
                    <div className="flex items-start justify-between">
                      <div>
                        <p className="text-sm font-normal leading-6 text-gray-500 dark:text-zinc-400">
                          Total Locked
                        </p>
                        <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                          {collateralLocked()}{' '}
                          <span className="text-sm font-normal">
                            {vault?.collateralToken.toUpperCase()}
                          </span>
                        </p>
                      </div>
                      <div className="flex items-center">
                        {isVaultOwner &&
                        canUnlockCollateral &&
                        vault?.stackedTokens > 0 &&
                        !loadingVaultData ? (
                          <button
                            type="button"
                            className="inline-flex items-center px-3 py-2 text-sm font-semibold leading-4 text-indigo-700 border border-transparent rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
                            onClick={() => unlockCollateral()}
                          >
                            <StyledIcon as="LockOpenIcon" size={4} className="-ml-0.5 mr-2" />
                            Unlock
                          </button>
                        ) : null}

                        {isVaultOwner && !loadingVaultData ? (
                          <button
                            type="button"
                            className="inline-flex items-center px-3 py-2 ml-2 text-sm font-medium leading-4 text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                            onClick={() => setShowDepositModal(true)}
                          >
                            Deposit
                          </button>
                        ) : null}
                      </div>
                    </div>

                    {canStackCollateral ? (
                      <PoxTimeline
                        unlockBurnHeight={unlockBurnHeight}
                        currentBurnHeight={burnBlockHeight}
                        isLoading={loadingStackerData}
                      />
                    ) : null}
                  </div>
                </div>

                <div className="mt-4 bg-white divide-y divide-gray-200 shadow dark:bg-zinc-800 dark:divide-zinc-600 sm:rounded-md sm:overflow-hidden">
                  <div className="px-4 py-5 sm:p-6">
                    {loadingStackerData || loadingVaultData ? (
                      <>
                        <div className="flex justify-between flex-1 mt-3">
                          <Placeholder
                            color={Placeholder.color.GRAY}
                            width={Placeholder.width.FULL}
                          />
                          <Placeholder
                            className="justify-end"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.THIRD}
                          />
                        </div>
                        <div className="flex justify-between flex-1 mt-4">
                          <Placeholder
                            color={Placeholder.color.GRAY}
                            width={Placeholder.width.FULL}
                          />
                          <Placeholder
                            className="justify-end"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.THIRD}
                          />
                        </div>
                      </>
                    ) : canStackCollateral ? (
                      <div>
                        <div className="sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                          <div className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                            {unlockBurnHeight == 0 ? (
                              <>
                                <p className="text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                                  Available for stacking
                                </p>
                                <Tooltip shouldWrapChildren={true} label={`...`}>
                                  <StyledIcon
                                    as="InformationCircleIcon"
                                    size={5}
                                    className="block ml-2 text-gray-400"
                                  />
                                </Tooltip>
                              </>
                            ) : (
                              <>
                                <p className="text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                                  Currently stacking
                                </p>
                                <Tooltip
                                  shouldWrapChildren={true}
                                  label={`The amount of STX that is currently stacking or will be stacking after your cooldown cycle`}
                                >
                                  <StyledIcon
                                    as="InformationCircleIcon"
                                    size={5}
                                    className="block ml-2 text-gray-400"
                                  />
                                </Tooltip>
                              </>
                            )}
                          </div>
                          <div className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                            <p className="text-lg font-semibold leading-none">
                              {enabledStacking ? (
                                <span>{microToReadable(vault?.collateral)} </span>
                              ) : (
                                <span>0 </span>
                              )}
                              <span className="text-sm font-normal">
                                {vault?.collateralToken.toUpperCase()}
                              </span>
                            </p>
                          </div>
                        </div>

                        {enabledStacking && stackingEndDate != '' ? (
                          <div className="mt-4 sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                            <div className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                              <p className="text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                                End of stacking
                              </p>
                              <Tooltip
                                shouldWrapChildren={true}
                                label={`The yield on your vault is given when stacking ends. If you opt-out of stacking, you can withdraw your funds when stacking ends.`}
                              >
                                <StyledIcon
                                  as="InformationCircleIcon"
                                  size={5}
                                  className="block ml-2 text-gray-400"
                                />
                              </Tooltip>
                            </div>
                            <div className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                              <p className="text-lg font-semibold leading-none">
                                {stackingEndDate}
                              </p>
                            </div>
                          </div>
                        ) : unlockBurnHeight == 0 ? (
                          <div className="mt-4 sm:grid sm:grid-flow-col sm:gap-4 sm:auto-cols-auto">
                            <div className="inline-flex items-center text-sm font-medium text-gray-500 dark:text-zinc-400">
                              <p className="text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                                Stacking starts in
                              </p>
                            </div>
                            <div className="mt-1 text-sm text-right text-gray-900 dark:text-zinc-100 sm:mt-0">
                              <p className="text-lg font-semibold leading-none">
                                {state.daysLeft} days
                              </p>
                            </div>
                          </div>
                        ) : null}
                      </div>
                    ) : null}

                    {canStackCollateral ? (
                      <>
                        <div className="flex items-center justify-between mt-4">
                          <div>
                            <p className="flex items-center text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                              Rewards
                              <Tooltip
                                className="ml-2"
                                shouldWrapChildren={true}
                                label={`The amount of yield that your vault has earned so far`}
                              >
                                <StyledIcon
                                  as="InformationCircleIcon"
                                  size={5}
                                  className="block ml-2 text-gray-400"
                                />
                              </Tooltip>
                            </p>
                            {loadingPoxYieldData ? (
                              <Placeholder
                                className="py-2"
                                color={Placeholder.color.INDIGO}
                                width={Placeholder.width.THIRD}
                              />
                            ) : (
                              <>
                                <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                                  {poxYield}{' '}
                                  <span className="text-sm font-normal">
                                    {vault?.collateralToken.toUpperCase()}
                                  </span>
                                </p>
                                <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                                  {usdaYield}{' '}
                                  <span className="text-sm font-normal">
                                    USDA
                                  </span>
                                </p>
                              </>
                            )}
                          </div>
                          {poxYield != 0 || usdaYield != 0 ? (
                            <div>
                              {poxYield != 0 ? (
                                <button
                                  type="button"
                                  className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                                  onClick={() => claimYield()}
                                >
                                  Add as collateral
                                </button>
                              ) : null}
                              {usdaYield != 0 ? (
                                <button
                                  type="button"
                                  className="inline-flex items-center px-3 py-2 ml-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                                  onClick={() => claimYieldPayDebt()}
                                >
                                  Claim USDA and burn debt
                                </button>
                              ) : null}
                            </div>
                          ) : null}
                        </div>
                      </>
                    ) : null}

                    <div
                      className={
                        canStackCollateral
                          ? `flex items-center justify-between mt-4`
                          : `flex items-center justify-between`
                      }
                    >
                      <div>
                        <p className="flex items-center text-base font-normal leading-6 text-gray-500 dark:text-zinc-400">
                          Withdrawal
                          <Tooltip
                            className="ml-2"
                            shouldWrapChildren={true}
                            label={`The amount of collateral you are able to withdraw while keeping a healthy collateralization level`}
                          >
                            <StyledIcon
                              as="InformationCircleIcon"
                              size={5}
                              className="block ml-2 text-gray-400"
                            />
                          </Tooltip>
                        </p>
                        {loadingVaultData ? (
                          <Placeholder
                            className="py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.THIRD}
                          />
                        ) : (
                          <p className="mt-1 text-lg font-semibold leading-none text-gray-900 dark:text-zinc-100">
                            {maximumCollateralToWithdraw}{' '}
                            <span className="text-sm font-normal">
                              {vault?.collateralToken.toUpperCase()}
                            </span>
                          </p>
                        )}
                      </div>
                      {isVaultOwner &&
                      canWithdrawCollateral &&
                      !loadingVaultData &&
                      maximumCollateralToWithdraw > 0 &&
                      totalDebt > 0 ? (
                        <button
                          type="button"
                          className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-indigo-700 bg-indigo-100 border border-transparent rounded-md hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
                          onClick={() => setShowWithdrawModal(true)}
                        >
                          Withdraw
                        </button>
                      ) : null}
                    </div>

                    {loadingVaultData ? (
                      <div className="mt-6">
                        <Alert>
                          <Placeholder
                            className="py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.FULL}
                          />
                          <Placeholder
                            className="py-2"
                            color={Placeholder.color.INDIGO}
                            width={Placeholder.width.FULL}
                          />
                        </Alert>
                      </div>
                    ) : canStackCollateral &&
                      isVaultOwner &&
                      vault?.stackedTokens > 0 &&
                      !vault?.revokedStacking ? (
                      // user has indicated they want to stack their tokens
                      <div className="mt-6">
                        <Alert>
                          {startedStacking && burnBlockHeight > unlockBurnHeight ? (
                            <p>
                              You can stop stacking and withdraw your collateral by unlocking your
                              vault with the above Unlock button.
                            </p>
                          ) : startedStacking ? (
                            <p>
                              You cannot withdraw your collateral since it is stacked until Bitcoin
                              block {unlockBurnHeight}. We are currently at Bitcoin block{' '}
                              {burnBlockHeight}. After block {unlockBurnHeight} gets mined, you will
                              need to manually unlock your vault to get access to your collateral.
                            </p>
                          ) : (
                            <p>
                              The next stacking cycle has not started yet. You can still choose to
                              opt-out of stacking your STX tokens. If you do so, you will not earn a
                              yield on your vault.
                            </p>
                          )}
                        </Alert>
                      </div>
                    ) : canStackCollateral &&
                      isVaultOwner &&
                      vault?.stackedTokens > 0 &&
                      vault?.revokedStacking ? (
                      <div className="mt-4">
                        <Alert>
                          <p>
                            You have unstacked your collateral. It is still stacking in PoX until
                            Bitcoin block {unlockBurnHeight}. Once your cooldown cycle hits, you can
                            unlock the collateral.
                          </p>
                        </Alert>
                      </div>
                    ) : canStackCollateral && isVaultOwner ? (
                      <div className="mt-4">
                        <Alert>
                          <p>You are not stacking your collateral.</p>
                        </Alert>
                      </div>
                    ) : null}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>
      </main>
    </Container>
  );
}