diff --git a/api/app/api/v1/whats_new.py b/api/app/api/v1/whats_new.py index b72cfdde..d56e20a2 100644 --- a/api/app/api/v1/whats_new.py +++ b/api/app/api/v1/whats_new.py @@ -1,6 +1,7 @@ from datetime import date, datetime from typing import Annotated from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +12,7 @@ from app.services.instance_settings import InstanceSettingsService from app.services.whats_new import WhatsNewService from shared.definitions.mode_features import CAP_BOUNTY_PROGRAMS, has_capability from shared.definitions.whats_new import ( + DEFAULT_ZONE, KIND_ORDER, MAX_TEXT_FILTER, NEW_WINDOWS, @@ -32,6 +34,16 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)] RINGS = {r.value for r in ProgramRing} +def _zone(tz: str) -> None: + try: + ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Unknown time zone {tz}.", + ) from exc + + async def _bounty(session: AsyncSession) -> bool: settings = await InstanceSettingsService(session).get_or_create() return has_capability(settings.mode, CAP_BOUNTY_PROGRAMS) @@ -53,7 +65,9 @@ async def whats_new( kinds: Annotated[str | None, Query(max_length=160)] = None, ring: Annotated[str, Query(max_length=16)] = ProgramRing.ENGAGED.value, q: Annotated[str | None, Query(max_length=MAX_TEXT_FILTER)] = None, + tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE, ) -> NewFeed: + _zone(tz) if window is not None and window not in NEW_WINDOWS: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, @@ -88,6 +102,7 @@ async def whats_new( kinds=wanted, ring=ring, q=q, + tz=tz, bounty=await _bounty(session), ) @@ -103,7 +118,9 @@ async def whats_new_visual( day_to: date | None = None, target_id: UUID | None = None, q: Annotated[str | None, Query(max_length=MAX_TEXT_FILTER)] = None, + tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE, ) -> VisualFeed: + _zone(tz) if window is not None and window not in NEW_WINDOWS: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, @@ -123,6 +140,7 @@ async def whats_new_visual( day_to=day_to, target_id=target_id, q=q, + tz=tz, ) @@ -132,9 +150,11 @@ async def whats_new_unseen( service: ServiceDep, session: SessionDep, project_id: Annotated[UUID, Query(description="Project ID")], + tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE, ) -> NewUnseen: + _zone(tz) count = await service.unseen( - project_id, current_user.id, bounty=await _bounty(session) + project_id, current_user.id, bounty=await _bounty(session), tz=tz ) return NewUnseen(count=count, since=await service.mark(current_user.id, project_id)) diff --git a/api/app/services/whats_new.py b/api/app/services/whats_new.py index f81febb2..363a0059 100644 --- a/api/app/services/whats_new.py +++ b/api/app/services/whats_new.py @@ -3,17 +3,18 @@ from __future__ import annotations from collections import defaultdict -from datetime import UTC, date, datetime, time, timedelta +from datetime import date, datetime, time, timedelta from uuid import UUID +from zoneinfo import ZoneInfo from sqlalchemy import ( DateTime, Uuid, - case, cast, column, exists, func, + literal, not_, or_, select, @@ -32,26 +33,28 @@ from shared.definitions.bounty_programs import ( ) from shared.definitions.vulnerabilities import ( SEVERITY_RANK, - SUPPRESSED_STATES, ) from shared.definitions.watch import ARRIVED_STATES, CT_SOURCE, WatchHostState from shared.definitions.whats_new import ( ALERT_SEVERITIES, BOUNTY_ROWS_PER_SECTION, DEFAULT_NEW_WINDOW, + DEFAULT_ZONE, ENGAGED_EVENTS, EVENT_KIND, EVIDENCE_FINDINGS, - GONE_KINDS, GRID_DAYS, GROUP_LIMIT, KIND_ORDER, NEW_WINDOWS, PROGRAM_EVENTS, + SCOPE_SOURCE, SOURCE_LABELS, + TERMS_KINDS, VISUAL_DISTANCE, VISUAL_FIELDS, VISUAL_LIMIT, + WATCH_SOURCE, Fact, NewBasis, NewKind, @@ -65,7 +68,7 @@ from shared.models.bounty_program import BountyEventRow, BountyProgram, BountySc from shared.models.scan import Scan from shared.models.subdomain import Subdomain from shared.models.target import Target, TargetOrganization -from shared.models.vulnerability import Vulnerability, VulnerabilityTriage +from shared.models.vulnerability import Vulnerability from shared.models.watch import ProgramWatch, UserMark, WatchHost from shared.models.whats_new import ( NewDay, @@ -78,28 +81,11 @@ from shared.models.whats_new import ( VisualFeed, VisualPair, ) +from shared.services.asset_query import vuln_suppressed from shared.services.asset_query.tokens import token from shared.services.scan_scope import census_only from shared.utils.datetime import utc_now -TERMS_KINDS = frozenset({NewKind.BOUNTY_TABLE.value, NewKind.RULES.value}) -WATCH_SOURCE = "watch" -SCOPE_SOURCE = "scope" - - -def _severity_rank(): - return case(SEVERITY_RANK, value=Vulnerability.severity, else_=len(SEVERITY_RANK)) - - -def _suppressed(): - return exists( - select(1).where( - VulnerabilityTriage.target_id == Vulnerability.target_id, - VulnerabilityTriage.fingerprint == Vulnerability.fingerprint, - VulnerabilityTriage.state.in_(SUPPRESSED_STATES), - ) - ) - def _seen_earlier(): earlier = aliased(Vulnerability) @@ -113,25 +99,20 @@ def _seen_earlier(): ) -def _new_conds(baseline: list[UUID], q: str | None) -> list: - base = [ +def _new_conds(baseline: list[UUID]) -> list: + return [ Vulnerability.scan_id.in_(baseline), Vulnerability.severity.in_(ALERT_SEVERITIES), not_(_seen_earlier()), - not_(_suppressed()), + not_(vuln_suppressed(tuple(baseline))), ] - if q: - base.append( - _text_match( - ( - Vulnerability.template_name, - Vulnerability.template_id, - Vulnerability.host, - ), - q, - ) - ) - return base + + +def _finding_text(q: str): + return _text_match( + (Vulnerability.template_name, Vulnerability.template_id, Vulnerability.host), + q, + ) def _text_match(columns, q: str): @@ -159,8 +140,16 @@ def _visual_value(value): return value or None -def _day_start(d: date) -> datetime: - return datetime.combine(d, time.min, tzinfo=UTC) +def _day_start(d: date, zone: ZoneInfo) -> datetime: + return datetime.combine(d, time.min, tzinfo=zone) + + +def _day_of(at: datetime, zone: ZoneInfo) -> str: + return at.astimezone(zone).date().isoformat() + + +def _local_date(column, zone: ZoneInfo): + return func.date(func.timezone(literal(zone.key, literal_execute=True), column)) class _Check: @@ -170,6 +159,7 @@ class _Check: self.kev = 0 self.severities: dict[str, int] = defaultdict(int) self.runs: dict[UUID, int] = defaultdict(int) + self.kev_runs: dict[UUID, int] = defaultdict(int) def rank(self): worst = min( @@ -180,12 +170,12 @@ class _Check: class _Groups: - def __init__(self): + def __init__(self, zone: ZoneInfo): + self.zone = zone self.by_id: dict[str, NewGroup] = {} self.counts: dict[str, int] = dict.fromkeys(KIND_ORDER, 0) self.facts: dict[str, dict[str, int]] = defaultdict(dict) self.daily: dict[str, dict[str, int]] = defaultdict(dict) - self.first_runs = 0 self.visual = 0 self.checks: dict[str, _Check] = {} @@ -268,22 +258,37 @@ class WhatsNewService: bounty: bool = True, rows: bool = True, grid: bool = True, + visual: bool = True, + tz: str = DEFAULT_ZONE, ) -> NewFeed: now = utc_now() + zone = ZoneInfo(tz) marked_at = await self.mark(user_id, project_id) basis, cutoff, until, window = self._period( - now, marked_at, since, window, day_from, day_to + now, marked_at, since, window, day_from, day_to, zone ) - grid_start = _day_start(now.date() - timedelta(days=GRID_DAYS - 1)) + grid_start = _day_start( + now.astimezone(zone).date() - timedelta(days=GRID_DAYS - 1), zone + ) range_start = min(cutoff, grid_start) if grid else cutoff wanted = set(kinds) if kinds else set(KIND_ORDER) q = (q or "").strip() or None program = await self._program(platform, handle) if bounty else None + out = _Groups(zone) + if platform and handle and program is None: + return NewFeed( + since=cutoff, + until=until, + basis=basis, + marked_at=marked_at, + window=window if basis == NewBasis.WINDOW.value else None, + counts=out.counts, + daily=self._days(grid_start, now, out.daily, zone) if grid else [], + ) target_ids = await self._target_ids(project_id, target_id, program) target_value = await self._target_value(project_id, target_id) - out = _Groups() await self._runs( out, @@ -297,7 +302,10 @@ class WhatsNewService: q, rows, ) - out.visual = await self._visual_count(project_id, cutoff, until, target_ids, q) + if visual: + out.visual = await self._visual_count( + project_id, cutoff, until, target_ids, q + ) if bounty: await self._bounty( out, @@ -330,20 +338,20 @@ class WhatsNewService: window=window if basis == NewBasis.WINDOW.value else None, counts=out.counts, facts=dict(out.facts), - daily=self._days(grid_start, now, out.daily) if grid else [], + daily=self._days(grid_start, now, out.daily, zone) if grid else [], groups=groups[:GROUP_LIMIT], + events=len(groups), truncated=truncated, - first_runs=out.first_runs, visual=out.visual, ) @staticmethod - def _period(now, marked_at, since, window, day_from, day_to): + def _period(now, marked_at, since, window, day_from, day_to, zone: ZoneInfo): until: datetime | None = None if day_from is not None: basis = NewBasis.DAYS.value - cutoff = _day_start(day_from) - until = _day_start((day_to or day_from) + timedelta(days=1)) + cutoff = _day_start(day_from, zone) + until = _day_start((day_to or day_from) + timedelta(days=1), zone) elif since is not None: basis, cutoff = NewBasis.MARK.value, since elif window: @@ -355,16 +363,18 @@ class WhatsNewService: basis, cutoff = NewBasis.WINDOW.value, now - NEW_WINDOWS[window] return basis, cutoff, until, window - async def unseen(self, project_id: UUID, user_id: UUID, *, bounty: bool) -> int: + async def unseen( + self, project_id: UUID, user_id: UUID, *, bounty: bool, tz: str = DEFAULT_ZONE + ) -> int: feed = await self.feed( - project_id, user_id, bounty=bounty, rows=False, grid=False + project_id, user_id, bounty=bounty, grid=False, visual=False, tz=tz ) - return sum(feed.counts.get(k, 0) for k in KIND_ORDER if k not in GONE_KINDS) + return feed.events @staticmethod - def _days(start: datetime, now: datetime, daily) -> list[NewDay]: - day = start.date() - end = now.date() + def _days(start: datetime, now: datetime, daily, zone: ZoneInfo) -> list[NewDay]: + day = start.astimezone(zone).date() + end = now.astimezone(zone).date() out: list[NewDay] = [] while day <= end: key = day.isoformat() @@ -483,28 +493,11 @@ class WhatsNewService: } if not scans: return - holding, baseline = await self._baseline_scans(scans) - for sid in holding - set(baseline): - at = scans[sid].started_at or scans[sid].created_at - if at >= since and (until is None or at < until): - out.first_runs += 1 + baseline = await self._baseline_scans(scans) if not baseline: return kind = NewKind.FINDING.value - base = _new_conds(baseline, q) - - if grid_start is not None: - daily = await self.session.execute( - select(func.date(Vulnerability.discovered_at), func.count()) - .where(*base, Vulnerability.discovered_at >= grid_start) - .group_by(func.date(Vulnerability.discovered_at)) - ) - for d, n in daily.all(): - out.day(kind, d, n) - - window = [*base, Vulnerability.discovered_at >= since] - if until is not None: - window.append(Vulnerability.discovered_at < until) + base = _new_conds(baseline) stmt = ( select( Vulnerability.scan_id, @@ -513,23 +506,37 @@ class WhatsNewService: func.max(Vulnerability.template_name), func.count(), func.count().filter(Vulnerability.is_kev), + func.max(Vulnerability.discovered_at), ) - .where(*window) + .where(*base) .group_by( Vulnerability.scan_id, Vulnerability.template_id, Vulnerability.severity, ) ) - found = (await self.session.execute(stmt)).all() - if not found: + by_run: dict[UUID, list] = defaultdict(list) + for row in (await self.session.execute(stmt)).all(): + by_run[row[0]].append(row) + if q and by_run: + matched = set( + ( + await self.session.execute( + select(Vulnerability.scan_id) + .where(*base, _finding_text(q)) + .distinct() + ) + ).scalars() + ) + by_run = {sid: r for sid, r in by_run.items() if sid in matched} + if not by_run: return targets = { t.id: t for t in ( await self.session.execute( select(Target).where( - Target.id.in_({scans[r[0]].target_id for r in found}) + Target.id.in_({scans[sid].target_id for sid in by_run}) ) ) ) @@ -537,25 +544,38 @@ class WhatsNewService: .all() } show = rows and kind in wanted - for sid, template_id, severity, name, n, kev in found: + for sid, found in by_run.items(): scan = scans[sid] - out.count(kind, n) - out.fact(kind, severity, n) - out.fact(kind, Fact.KEV.value, kev) - if not show: + at = scan.completed_at or max(r[6] for r in found) + total = sum(r[4] for r in found) + if grid_start is not None and at >= grid_start: + day = _day_of(at, out.zone) + out.day(kind, day, total) + for r in found: + out.day(r[2], day, r[4]) + if at < since or (until is not None and at >= until): continue - g = self._run_group(out, scan, targets[scan.target_id]) - g.severities[severity] = g.severities.get(severity, 0) + n - c = out.check(template_id) - c.name = name - c.kev += kev - c.severities[severity] += n - c.runs[sid] += n + out.count(kind, total) + g = ( + self._run_group(out, scan, targets[scan.target_id], at) + if show + else None + ) + for _, template_id, severity, name, n, kev, _last in found: + out.fact(kind, severity, n) + out.fact(kind, Fact.KEV.value, kev) + if g is None: + continue + g.severities[severity] = g.severities.get(severity, 0) + n + c = out.check(template_id) + c.name = name + c.kev += kev + c.kev_runs[sid] += kev + c.severities[severity] += n + c.runs[sid] += n + if g is not None: + out.section(g, kind, total, []) if show: - for g in out.by_id.values(): - if g.scan_id is not None: - total = sum(g.severities.values()) - out.section(g, kind, total, []) await self._previous_runs(out, scans) async def _visual_count(self, project_id, since, until, target_ids, q) -> int: @@ -636,11 +656,12 @@ class WhatsNewService: target_id: UUID | None = None, q: str | None = None, limit: int = VISUAL_LIMIT, + tz: str = DEFAULT_ZONE, ) -> VisualFeed: now = utc_now() marked_at = await self.mark(user_id, project_id) basis, cutoff, until, window = self._period( - now, marked_at, since, window, day_from, day_to + now, marked_at, since, window, day_from, day_to, ZoneInfo(tz) ) q = (q or "").strip() or None target_ids = [target_id] if target_id is not None else None @@ -719,9 +740,7 @@ class WhatsNewService: feed.total = len(feed.pairs) return feed - async def _baseline_scans( - self, scans: dict[UUID, Scan] - ) -> tuple[set[UUID], list[UUID]]: + async def _baseline_scans(self, scans: dict[UUID, Scan]) -> list[UUID]: """Scans holding findings, and those of them with an earlier scan that held findings.""" firsts = ( await self.session.execute( @@ -731,7 +750,7 @@ class WhatsNewService: ) ).all() if not firsts: - return set(), [] + return [] table = values( column("id", Uuid), column("target_id", Uuid), @@ -750,7 +769,7 @@ class WhatsNewService: ) ) ) - return {sid for sid, _ in firsts}, [r[0] for r in rows.all()] + return [r[0] for r in rows.all()] @staticmethod def _evidence(out: _Groups, groups: list[NewGroup]) -> None: @@ -774,14 +793,15 @@ class WhatsNewService: key=lambda sev: SEVERITY_RANK.get(sev, len(SEVERITY_RANK)), default=None, ), - kev=c.kev, + kev=c.kev_runs[g.scan_id], query=f"is:new {token('template', '=', c.template_id)}", ) for c in picked ] - def _run_group(self, out: _Groups, scan: Scan, target: Target) -> NewGroup: - at = scan.completed_at or scan.started_at or scan.created_at + def _run_group( + self, out: _Groups, scan: Scan, target: Target, at: datetime + ) -> NewGroup: g = out.group( f"run:{scan.id}", NewSubject( @@ -939,11 +959,14 @@ class WhatsNewService: daily = await self.session.execute( select( BountyEventRow.kind, - func.date(BountyEventRow.created_at), + _local_date(BountyEventRow.created_at, out.zone), func.count(), ) .where(*base, BountyEventRow.created_at >= grid_start) - .group_by(BountyEventRow.kind, func.date(BountyEventRow.created_at)) + .group_by( + BountyEventRow.kind, + _local_date(BountyEventRow.created_at, out.zone), + ) ) for kind, d, n in daily.all(): out.day(EVENT_KIND[kind], d, n) @@ -994,7 +1017,7 @@ class WhatsNewService: if kind not in wanted: continue watch_id = watches.get(e.program_id) - day = e.created_at.date().isoformat() + day = _day_of(e.created_at, out.zone) if kind == NewKind.PROGRAM.value: gid = f"library:programs:{day}" subjects[gid] = NewSubject( @@ -1174,11 +1197,11 @@ class WhatsNewService: if grid_start is not None: daily = await self.session.execute( - select(func.date(WatchHost.first_seen_at), func.count()) + select(_local_date(WatchHost.first_seen_at, out.zone), func.count()) .select_from(WatchHost) .join(*join) .where(*base, WatchHost.first_seen_at >= grid_start) - .group_by(func.date(WatchHost.first_seen_at)) + .group_by(_local_date(WatchHost.first_seen_at, out.zone)) ) for d, n in daily.all(): out.day(kind, d, n) @@ -1214,7 +1237,7 @@ class WhatsNewService: subjects: dict[str, NewSubject] = {} latest: dict[str, datetime] = {} for h, w, p, t in (await self.session.execute(stmt)).all(): - gid = f"program:{p.id}:{h.first_seen_at.date().isoformat()}" + gid = f"program:{p.id}:{_day_of(h.first_seen_at, out.zone)}" subjects[gid] = NewSubject( kind=SubjectKind.PROGRAM.value, id=str(p.id), @@ -1284,9 +1307,9 @@ class WhatsNewService: if grid_start is not None: daily = await self.session.execute( - select(func.date(Target.created_at), func.count()) + select(_local_date(Target.created_at, out.zone), func.count()) .where(*base, Target.created_at >= grid_start) - .group_by(func.date(Target.created_at)) + .group_by(_local_date(Target.created_at, out.zone)) ) for d, n in daily.all(): out.day(kind, d, n) @@ -1361,7 +1384,7 @@ class WhatsNewService: ) by_day: dict[str, list[NewItem]] = defaultdict(list) for item in items: - by_day[item.at.date().isoformat()].append(item) + by_day[_day_of(item.at, out.zone)].append(item) for day, listed in by_day.items(): g = out.group( f"targets:{day}", diff --git a/frontend/src/lib/api/whats-new.ts b/frontend/src/lib/api/whats-new.ts index 79774341..a561406c 100644 --- a/frontend/src/lib/api/whats-new.ts +++ b/frontend/src/lib/api/whats-new.ts @@ -6,6 +6,7 @@ import type { VisualFeed, VisualParams } from '$lib/types/whats-new'; +import { viewerZone } from '$lib/utilities/dates'; import { api } from './client'; function query(params: Record): string { @@ -19,13 +20,19 @@ function query(params: Record): string { export const whatsNewApi = { feed(projectId: string, params: NewFeedParams = {}): Promise { - return api.get(`/whats-new${query({ project_id: projectId, ...params })}`); + return api.get( + `/whats-new${query({ project_id: projectId, tz: viewerZone(), ...params })}` + ); }, visual(projectId: string, params: VisualParams = {}): Promise { - return api.get(`/whats-new/visual${query({ project_id: projectId, ...params })}`); + return api.get( + `/whats-new/visual${query({ project_id: projectId, tz: viewerZone(), ...params })}` + ); }, unseen(projectId: string): Promise { - return api.get(`/whats-new/unseen${query({ project_id: projectId })}`); + return api.get( + `/whats-new/unseen${query({ project_id: projectId, tz: viewerZone() })}` + ); }, caughtUp(projectId: string): Promise { return api.post(`/whats-new/seen${query({ project_id: projectId })}`, {}); diff --git a/frontend/src/lib/components/whats-new/activity-grid.svelte b/frontend/src/lib/components/whats-new/activity-grid.svelte deleted file mode 100644 index 45ea55b6..00000000 --- a/frontend/src/lib/components/whats-new/activity-grid.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - - - -
-
- {#each months as m (m.col)} - {m.label} - {/each} -
-
-
- {#each WEEKDAYS as w, i (i)} - {w} - {/each} -
-
- {#each cells as cell (cell.day.date)} - {@const n = total(cell.day)} - {@const s = step(n)} - {@const on = inRange(cell.day.date)} - - {#snippet child(props)} - - {/snippet} - - {/each} -
-
-
diff --git a/frontend/src/lib/components/whats-new/activity-lanes.svelte b/frontend/src/lib/components/whats-new/activity-lanes.svelte new file mode 100644 index 00000000..a22c4aed --- /dev/null +++ b/frontend/src/lib/components/whats-new/activity-lanes.svelte @@ -0,0 +1,316 @@ + + +
+
+ {#each lanes as kind (kind)} + {@const on = active === kind} + + {/each} +
+ +
+ {#if lanes.length === 0} +
+ No events in {span} days +
+ {:else if width > 0} + { + if (!dragging) hover = null; + }} + > + {#if periodX !== null && !range} + + {/if} + + {#each lanes as kind, li (kind)} + + {/each} + + {#if range} + + + {/if} + + {#if hover !== null} + + {/if} + + {#if markX !== null} + + {/if} + + {#each lanes as kind, li (kind)} + {#each shown as day, i (day.date)} + {@const n = day.counts[kind] ?? 0} + {#if n} + + {/if} + {/each} + {/each} + + {#each ticks as { d, i } (d.date)} + {label(d.date)} + {/each} + + + {#if hovered && hover !== null && !dragging} +
+
{label(hovered.date, true)}
+ {#each tipRows as row (row.key)} +
+ + + {row.label} + + {row.n.toLocaleString()} +
+ {:else} +
Nothing new
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/frontend/src/lib/components/whats-new/event-row.svelte b/frontend/src/lib/components/whats-new/event-row.svelte index 238ead63..b331b621 100644 --- a/frontend/src/lib/components/whats-new/event-row.svelte +++ b/frontend/src/lib/components/whats-new/event-row.svelte @@ -1,16 +1,17 @@ -
  • onPick(index)} > - + @@ -159,88 +179,59 @@ -
    -
    - - {#if isRun} - Rescan of - {subject.label} - +
    +
    + - {#if isRun} - {#if group.evidence.length} - - {/if} - {#if group.more} - - {group.more} more {group.more === 1 ? 'check' : 'checks'} - - {/if} - {:else} - {#if subject.kind === SubjectKind.PROGRAM} - - {group.sections.map((s) => sentence(s.kind, s.total)).join(' · ')} - - {/if} - {#if shown.length} - -
    - {#each shown as item (item.id)} + {#if expanded} + {#if isRun && group.scan_id} + onFinding(v, group.scan_id ?? '')} + /> + {:else if items.length} +
    + {#each items as item (item.id)} {/each}
    - {/if} - {#if hiddenItems > 0 || (expanded && items.length > PREVIEW)} - + {#if total > items.length} + + {items.length} of {total.toLocaleString()} shown + + {/if} {/if} {/if}
    - {#if isRun} + {#if isRun && group.scan_id} {#if group.previous_scan_id}
    {#if mono} {:else if item.kind === NewKind.TARGET}
    diff --git a/frontend/src/lib/components/whats-new/new-strip.svelte b/frontend/src/lib/components/whats-new/new-strip.svelte index b8ef1712..2e1fdf1f 100644 --- a/frontend/src/lib/components/whats-new/new-strip.svelte +++ b/frontend/src/lib/components/whats-new/new-strip.svelte @@ -1,127 +1,62 @@ -{#snippet stat(label: string, signal: SignalKey | null, n: number, sub = '', prefix = '')} - {@const on = signal !== null && active === signal} - -{/snippet} - {#if !feed}
    - {#each ['w-14', 'w-24', 'w-28', 'w-16', 'w-20'] as w (w)} -
    - - - -
    - {/each} - +
    + + +
    +
    +
    + {#each { length: 3 } as _, i (i)} + + {/each} +
    {:else} -
    -
    - Since - {since} -
    - - {#if shows(NewKind.FINDING)} -
    - -
    +
    +
    + {period} +
    + + {feed.events.toLocaleString()} + + + {feed.events === 1 ? 'event' : 'events'} + +
    + {#if kinds.includes(NewKind.FINDING)} +
    {#each FINDINGS as f (f.signal)} {@const on = active === f.signal} - {@const n = fact(NewKind.FINDING, f.signal)} + {@const n = facts[f.signal] ?? 0}
    - {/if} + {/if} +
    - {#each bountyStats as b (b.kind)} - {@render stat(KIND_LABELS[b.kind], b.kind, counts[b.kind] ?? 0, b.sub)} - {/each} - - {#if feed.daily.length} -
    - Last 13 weeks - -
    - -
    -
    -
    - {/if} +
    {/if} diff --git a/frontend/src/lib/components/whats-new/run-findings.svelte b/frontend/src/lib/components/whats-new/run-findings.svelte new file mode 100644 index 00000000..3996feb2 --- /dev/null +++ b/frontend/src/lib/components/whats-new/run-findings.svelte @@ -0,0 +1,81 @@ + + +{#if failed} +

    Findings not loaded.

    +{:else if items === null} +
    + {#each { length: Math.min(count, 4) } as _, i (i)} + + {/each} +
    +{:else} +
      + {#each items as v (v.id)} +
    • + +
    • + {/each} +
    + {#if total > items.length} + + {items.length} of {total.toLocaleString()} shown + + {/if} +{/if} diff --git a/frontend/src/lib/components/whats-new/visual-compare-dialog.svelte b/frontend/src/lib/components/whats-new/visual-compare-dialog.svelte index 655e99d3..9be78ccb 100644 --- a/frontend/src/lib/components/whats-new/visual-compare-dialog.svelte +++ b/frontend/src/lib/components/whats-new/visual-compare-dialog.svelte @@ -125,7 +125,7 @@ {pair.host} {pair.target_value}{pair.target_value} Distance {pair.distance} {#if pair.silent} diff --git a/frontend/src/lib/components/whats-new/visual-pairs.svelte b/frontend/src/lib/components/whats-new/visual-pairs.svelte index a9014bd5..9c5dad7e 100644 --- a/frontend/src/lib/components/whats-new/visual-pairs.svelte +++ b/frontend/src/lib/components/whats-new/visual-pairs.svelte @@ -58,7 +58,7 @@