Exclude maintenance history from activity log
This commit is contained in:
@@ -35,6 +35,7 @@ export interface ActivityRow {
|
|||||||
|
|
||||||
export interface ActivityQuery {
|
export interface ActivityQuery {
|
||||||
excludeProvisioning?: boolean;
|
excludeProvisioning?: boolean;
|
||||||
|
excludeMaintenance?: boolean;
|
||||||
server?: string;
|
server?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
type?: ActivityType;
|
type?: ActivityType;
|
||||||
@@ -69,6 +70,7 @@ export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
|
|||||||
offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
|
offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
|
||||||
};
|
};
|
||||||
if (params.get('excludeProvisioning') === 'true') query.excludeProvisioning = true;
|
if (params.get('excludeProvisioning') === 'true') query.excludeProvisioning = true;
|
||||||
|
if (params.get('excludeMaintenance') === 'true') query.excludeMaintenance = true;
|
||||||
for (const key of ['server', 'host', 'q'] as const) {
|
for (const key of ['server', 'host', 'q'] as const) {
|
||||||
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
|
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
|
||||||
if (value) query[key] = value;
|
if (value) query[key] = value;
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ normalized AS (
|
|||||||
|
|
||||||
type SqlRow = Omit<ActivityRow, 'source' | 'sources' | 'href'> & { sources: string; route: string; recordId: string; secondaryHostId: string | null };
|
type SqlRow = Omit<ActivityRow, 'source' | 'sources' | 'href'> & { sources: string; route: string; recordId: string; secondaryHostId: string | null };
|
||||||
const sourceOrder: ActivitySource[] = ['maintenance', 'recovery', 'campaign', 'beta', 'autoscaling', 'provisioning', 'host'];
|
const sourceOrder: ActivitySource[] = ['maintenance', 'recovery', 'campaign', 'beta', 'autoscaling', 'provisioning', 'host'];
|
||||||
|
// Use the same membership rule for Maintenance history and its exclusion from the main log.
|
||||||
|
const maintenanceMembershipSql = "(instr(sources,'|maintenance|')>0 OR instr(sources,'|recovery|')>0)";
|
||||||
function present(row: SqlRow): ActivityRow {
|
function present(row: SqlRow): ActivityRow {
|
||||||
const sources = sourceOrder.filter(source => row.sources.includes(`|${source}|`));
|
const sources = sourceOrder.filter(source => row.sources.includes(`|${source}|`));
|
||||||
const href = row.route === 'maintenance-job' ? `/maintenance?view=overview&job=${encodeURIComponent(row.recordId)}#job-${row.recordId}`
|
const href = row.route === 'maintenance-job' ? `/maintenance?view=overview&job=${encodeURIComponent(row.recordId)}#job-${row.recordId}`
|
||||||
@@ -154,12 +156,13 @@ export function activityHistory(input: ActivityQuery = {}, now = Date.now()): Ac
|
|||||||
// Keep lifecycle jobs in Provisioning, including jobs triggered by automation.
|
// Keep lifecycle jobs in Provisioning, including jobs triggered by automation.
|
||||||
// Apply this before totals and pagination; resource histories can still include them.
|
// Apply this before totals and pagination; resource histories can still include them.
|
||||||
if (query.excludeProvisioning) conditions.push("route <> 'provisioning' AND type <> 'restart'");
|
if (query.excludeProvisioning) conditions.push("route <> 'provisioning' AND type <> 'restart'");
|
||||||
|
if (query.excludeMaintenance) conditions.push(`NOT ${maintenanceMembershipSql}`);
|
||||||
const bindings: Record<string, string | number> = {};
|
const bindings: Record<string, string | number> = {};
|
||||||
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
|
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
|
||||||
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
|
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
|
||||||
if (query.source) {
|
if (query.source) {
|
||||||
// Maintenance is the user-facing umbrella; Recovery remains available as a narrower filter.
|
// Maintenance is the user-facing umbrella; Recovery remains available as a narrower filter.
|
||||||
conditions.push(query.source === 'maintenance' ? "(instr(sources,'|maintenance|')>0 OR instr(sources,'|recovery|')>0)" : 'instr(sources,@source)>0');
|
conditions.push(query.source === 'maintenance' ? maintenanceMembershipSql : 'instr(sources,@source)>0');
|
||||||
if (query.source !== 'maintenance') bindings.source = `|${query.source}|`;
|
if (query.source !== 'maintenance') bindings.source = `|${query.source}|`;
|
||||||
}
|
}
|
||||||
if (query.days) { conditions.push('julianday(at)>=julianday(@since)'); bindings.since = new Date(now - query.days * 86_400_000).toISOString(); }
|
if (query.days) { conditions.push('julianday(at)>=julianday(@since)'); bindings.since = new Date(now - query.days * 86_400_000).toISOString(); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
|
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
|
||||||
import { createTimeFormatter } from '../time-zone.js';
|
import { createTimeFormatter } from '../time-zone.js';
|
||||||
|
|
||||||
export interface ActivityScope { server?: string; source?: ActivitySource; excludeProvisioning?: boolean }
|
export interface ActivityScope { server?: string; source?: ActivitySource; excludeProvisioning?: boolean; excludeMaintenance?: boolean }
|
||||||
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
|
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
|
||||||
|
|
||||||
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
|
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
|
||||||
@@ -10,6 +10,7 @@ export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuer
|
|||||||
if (scope.server !== undefined) params.set('server', scope.server);
|
if (scope.server !== undefined) params.set('server', scope.server);
|
||||||
if (scope.source !== undefined) params.set('source', scope.source);
|
if (scope.source !== undefined) params.set('source', scope.source);
|
||||||
if (scope.excludeProvisioning !== undefined) params.set('excludeProvisioning', String(scope.excludeProvisioning));
|
if (scope.excludeProvisioning !== undefined) params.set('excludeProvisioning', String(scope.excludeProvisioning));
|
||||||
|
if (scope.excludeMaintenance !== undefined) params.set('excludeMaintenance', String(scope.excludeMaintenance));
|
||||||
return parseActivityQuery(params);
|
return parseActivityQuery(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import Freshness from './Freshness.svelte';
|
import Freshness from './Freshness.svelte';
|
||||||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||||||
|
|
||||||
let { server, source, initial, excludeProvisioning = false }: { server?: string; source?: ActivitySource; initial?: ActivityPage; excludeProvisioning?: boolean } = $props();
|
let { server, source, initial, excludeProvisioning = false, excludeMaintenance = false }: { server?: string; source?: ActivitySource; initial?: ActivityPage; excludeProvisioning?: boolean; excludeMaintenance?: boolean } = $props();
|
||||||
const id = $props.id();
|
const id = $props.id();
|
||||||
const headingId = `${id}-activity-heading`;
|
const headingId = `${id}-activity-heading`;
|
||||||
let data = $state<ActivityPage | undefined>(untrack(() => initial));
|
let data = $state<ActivityPage | undefined>(untrack(() => initial));
|
||||||
@@ -27,14 +27,22 @@
|
|||||||
let filtersOpen = $state(false);
|
let filtersOpen = $state(false);
|
||||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||||
let results = $state<HTMLOListElement>();
|
let results = $state<HTMLOListElement>();
|
||||||
let search = $state(untrack(() => activityQuery($page.url, { server, source, excludeProvisioning }).q ?? ''));
|
let search = $state(untrack(() => activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }).q ?? ''));
|
||||||
let pendingUrl: URL | undefined;
|
let pendingUrl: URL | undefined;
|
||||||
let navigationId = 0;
|
let navigationId = 0;
|
||||||
const filters = $derived(activityQuery($page.url, { server, source, excludeProvisioning }));
|
const filters = $derived(activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
|
||||||
const apiUrl = $derived(activityApiHref($page.url, { server, source, excludeProvisioning }));
|
const apiUrl = $derived(activityApiHref($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
|
||||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||||
const typeOptions = $derived(Object.entries(activityTypes).filter(([key]) => !excludeProvisioning || !['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key)));
|
const typeOptions = $derived(Object.entries(activityTypes).filter(([key]) =>
|
||||||
const sourceOptions = $derived(Object.entries(activitySources).filter(([key]) => !excludeProvisioning || !['provisioning', 'autoscaling'].includes(key)));
|
(!excludeProvisioning || !['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key))
|
||||||
|
&& (!excludeMaintenance || !['update', 'health'].includes(key))));
|
||||||
|
const sourceOptions = $derived(Object.entries(activitySources).filter(([key]) =>
|
||||||
|
(!excludeProvisioning || !['provisioning', 'autoscaling'].includes(key))
|
||||||
|
&& (!excludeMaintenance || !['maintenance', 'recovery'].includes(key))));
|
||||||
|
const historyDescription = $derived(excludeMaintenance
|
||||||
|
? 'File changes and host work appear here.'
|
||||||
|
: excludeProvisioning ? 'File changes, maintenance, and host work appear here.'
|
||||||
|
: 'Server operations, file changes, maintenance, and host work appear here.');
|
||||||
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
|
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
|
||||||
const serverOptions = $derived.by(() => {
|
const serverOptions = $derived.by(() => {
|
||||||
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
|
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
|
||||||
@@ -125,7 +133,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : excludeProvisioning ? 'File changes, maintenance, and host work appear here.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
|
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : historyDescription}>
|
||||||
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||||||
</EmptyState>{/if}
|
</EmptyState>{/if}
|
||||||
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
|
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { parseActivityQuery } from '$lib/activity.js';
|
|||||||
import { activityHistory } from '$lib/server/activity.js';
|
import { activityHistory } from '$lib/server/activity.js';
|
||||||
|
|
||||||
export function load({ url }: { url: URL }) {
|
export function load({ url }: { url: URL }) {
|
||||||
return { activity: activityHistory({ ...parseActivityQuery(url.searchParams), excludeProvisioning: true }) };
|
return { activity: activityHistory({ ...parseActivityQuery(url.searchParams), excludeProvisioning: true, excludeMaintenance: true }) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
|
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Activity" subtitle="Releases, maintenance, and host activity. Server jobs are in Provisioning." />
|
<SectionHeader title="Activity" subtitle="Releases and host activity. Server jobs are in Provisioning; maintenance and recovery are in Maintenance history." />
|
||||||
<ActivityLog initial={data.activity} excludeProvisioning />
|
<ActivityLog initial={data.activity} excludeProvisioning excludeMaintenance />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -58,6 +58,37 @@ describe('Activity query parameters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('unified retained Activity', () => {
|
describe('unified retained Activity', () => {
|
||||||
|
it('excludes everything in Maintenance history, including shared recovery campaigns, without changing that history', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.maintenance(1);
|
||||||
|
api.maintenance(2);
|
||||||
|
api.handle.prepare("UPDATE maintenance_jobs SET kind='schedule' WHERE id=2").run();
|
||||||
|
api.operation('shared');
|
||||||
|
api.maintenance(3, 'failed', 'shared');
|
||||||
|
api.link('shared', 'campaign', 'release');
|
||||||
|
api.incident('health');
|
||||||
|
api.incident('drift', 'drift');
|
||||||
|
api.remote('check', { kind: 'instance.inspect', routine: 1 });
|
||||||
|
for (const [id, trigger] of [['recovery', 'watchdog'], ['release', 'ui']]) {
|
||||||
|
api.handle.prepare("INSERT INTO deployments(id,action,started_at,triggered_by) VALUES(?,'drift',?,?)").run(id, at, trigger);
|
||||||
|
api.handle.prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES(?,?,1,'done')").run(id, server);
|
||||||
|
}
|
||||||
|
const maintenance = api.activityHistory({ source: 'maintenance' });
|
||||||
|
expect(maintenance.total).toBe(6);
|
||||||
|
const query = { excludeProvisioning: true, excludeMaintenance: true, limit: 1 };
|
||||||
|
const first = api.activityHistory(query);
|
||||||
|
const second = api.activityHistory({ ...query, offset: 1 });
|
||||||
|
expect(first.total).toBe(2);
|
||||||
|
expect(second.total).toBe(2);
|
||||||
|
expect([...first.rows, ...second.rows].map(row => row.id).sort()).toEqual([`campaign:release:${server}`, 'host:check']);
|
||||||
|
for (const source of ['maintenance', 'recovery'] as const) {
|
||||||
|
expect(api.activityHistory({ ...query, source }).total).toBe(0);
|
||||||
|
}
|
||||||
|
expect(api.activityHistory({ ...query, source: 'campaign' }).total).toBe(1);
|
||||||
|
expect(api.activityHistory({ source: 'maintenance' })).toEqual(maintenance);
|
||||||
|
expect(api.activityHistory({ server }).total).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
it('excludes provisioning jobs from log totals and pages regardless of action or trigger, while retaining contextual history', async () => {
|
it('excludes provisioning jobs from log totals and pages regardless of action or trigger, while retaining contextual history', async () => {
|
||||||
const api = await setup();
|
const api = await setup();
|
||||||
for (const action of ['create', 'start', 'restart', 'rebuild', 'stop', 'destroy']) {
|
for (const action of ['create', 'start', 'restart', 'rebuild', 'stop', 'destroy']) {
|
||||||
@@ -233,7 +264,9 @@ describe('unified retained Activity', () => {
|
|||||||
const api = await setup();
|
const api = await setup();
|
||||||
api.operation('a'); api.operation('b', { state: 'failed' });
|
api.operation('a'); api.operation('b', { state: 'failed' });
|
||||||
api.remote('failed-check', { kind: 'instance.inspect', routine: 1, state: 'failed' });
|
api.remote('failed-check', { kind: 'instance.inspect', routine: 1, state: 'failed' });
|
||||||
const url = new URL('http://localhost/activity?state=failed&limit=1&excludeProvisioning=true');
|
api.maintenance(1, 'failed');
|
||||||
|
api.incident('finding', 'health', 'attention');
|
||||||
|
const url = new URL('http://localhost/activity?state=failed&limit=1&excludeProvisioning=true&excludeMaintenance=true');
|
||||||
const { GET } = await import('../../src/routes/api/activity/+server.js');
|
const { GET } = await import('../../src/routes/api/activity/+server.js');
|
||||||
const { load } = await import('../../src/routes/activity/+page.server.js');
|
const { load } = await import('../../src/routes/activity/+page.server.js');
|
||||||
const response = GET({ url });
|
const response = GET({ url });
|
||||||
@@ -241,5 +274,6 @@ describe('unified retained Activity', () => {
|
|||||||
expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity)));
|
expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity)));
|
||||||
expect(load({ url }).activity.rows[0].id).toBe('host:failed-check');
|
expect(load({ url }).activity.rows[0].id).toBe('host:failed-check');
|
||||||
expect(load({ url: new URL('http://localhost/activity?excludeProvisioning=false') }).activity.rows.some(row => row.id.startsWith('provisioning:'))).toBe(false);
|
expect(load({ url: new URL('http://localhost/activity?excludeProvisioning=false') }).activity.rows.some(row => row.id.startsWith('provisioning:'))).toBe(false);
|
||||||
|
expect(load({ url: new URL('http://localhost/activity?excludeMaintenance=false') }).activity.rows.map(row => row.id)).toEqual(['host:failed-check']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ it('keeps provisioning excluded from log polling after filtering or clearing fil
|
|||||||
expect(activityQuery(new URL('http://localhost/servers/town?tab=history')).excludeProvisioning).toBeUndefined();
|
expect(activityQuery(new URL('http://localhost/servers/town?tab=history')).excludeProvisioning).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps maintenance excluded from log polling while preserving the Maintenance history scope', () => {
|
||||||
|
const url = new URL('http://localhost/activity?source=maintenance&excludeMaintenance=false');
|
||||||
|
const logScope = { excludeProvisioning: true, excludeMaintenance: true };
|
||||||
|
expect(new URL(activityApiHref(url, logScope), url).searchParams.get('excludeMaintenance')).toBe('true');
|
||||||
|
const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url);
|
||||||
|
expect(activityQuery(cleared, logScope).excludeMaintenance).toBe(true);
|
||||||
|
const maintenance = new URL('http://localhost/maintenance?view=activity');
|
||||||
|
const query = new URL(activityApiHref(maintenance, { source: 'maintenance', excludeMaintenance: false }), maintenance).searchParams;
|
||||||
|
expect(query.get('source')).toBe('maintenance');
|
||||||
|
expect(query.has('excludeMaintenance')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
|
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
|
||||||
const now = Date.parse('2026-10-07T18:00:00Z');
|
const now = Date.parse('2026-10-07T18:00:00Z');
|
||||||
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
|
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
|
||||||
@@ -61,6 +73,8 @@ it('renders one page heading with provisioning management and filters removed',
|
|||||||
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).not.toContain('Manage servers'); expect(body).not.toContain('href="/provisioning"');
|
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).not.toContain('Manage servers'); expect(body).not.toContain('href="/provisioning"');
|
||||||
expect(body).not.toContain('<option value="create"'); expect(body).not.toContain('<option value="provisioning"');
|
expect(body).not.toContain('<option value="create"'); expect(body).not.toContain('<option value="provisioning"');
|
||||||
expect(body).not.toContain('<option value="restart"');
|
expect(body).not.toContain('<option value="restart"');
|
||||||
|
for (const value of ['update', 'health', 'maintenance', 'recovery']) expect(body).not.toContain(`<option value="${value}"`);
|
||||||
|
expect(body).toContain('<option value="drift"');
|
||||||
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
|
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
|
||||||
expect(viewer).not.toContain('Manage servers');
|
expect(viewer).not.toContain('Manage servers');
|
||||||
});
|
});
|
||||||
@@ -91,6 +105,7 @@ it('offers one mobile filter sheet with distinct IDs and hides fixed scope contr
|
|||||||
scope('/maintenance?view=activity&state=failed');
|
scope('/maintenance?view=activity&state=failed');
|
||||||
const fixed = render(ActivityLog, { props: { initial: report(), server: row.server!, source: 'maintenance' } }).body;
|
const fixed = render(ActivityLog, { props: { initial: report(), server: row.server!, source: 'maintenance' } }).body;
|
||||||
expect(fixed).not.toContain('id="activity-server'); expect(fixed).not.toContain('id="activity-source');
|
expect(fixed).not.toContain('id="activity-server'); expect(fixed).not.toContain('id="activity-source');
|
||||||
|
expect(fixed).toContain('<option value="update"'); expect(fixed).toContain('<option value="health"');
|
||||||
expect(fixed).toContain('view=activity'); expect(fixed).toContain('state=failed'); expect(fixed).toContain('Filters · 1');
|
expect(fixed).toContain('view=activity'); expect(fixed).toContain('state=failed'); expect(fixed).toContain('Filters · 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user