This commit is contained in:
jarek committed 2026-07-11 15:31:52 +02:00
1 parent 685132ff89
commit c718ac9a84
101 files changed
+5097 -642

No files matched your search

+72
View File
@@ -82,3 +82,75 @@ export function formatTimePartWith(
: `${hour24}:${minutes}`;
}
}
/**
* Offset of a timezone from UTC (in ms) at a given instant.
* Positive for zones ahead of UTC (e.g. UTC+2 -> 7_200_000).
*/
function tzOffsetMs(timeZone: string, at: Date): number {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const parts = dtf.formatToParts(at);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
// Drop sub-second precision of `at` - formatToParts only has seconds
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
}
/**
* Current calendar date in a timezone as 'YYYY-MM-DD'.
* Falsy timeZone means the browser's local timezone.
*/
export function currentDateInTimezone(timeZone?: string): string {
const now = new Date();
if (!timeZone) {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(now);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
/**
* UTC instant (ISO string) of a day boundary in a timezone: midnight for the
* start of the day, 23:59:59.999 for the end. Used to turn date-only filter
* values ('YYYY-MM-DD', meaning a day in the user's configured timezone) into
* timestamps comparable against UTC-stored event times (#1269).
* Falsy timeZone means the browser's local timezone.
*/
export function dayBoundaryToUtcISO(dateStr: string, timeZone: string | undefined, endOfDay: boolean): string {
const [y, m, d] = dateStr.split('-').map(Number);
const h = endOfDay ? 23 : 0;
const min = endOfDay ? 59 : 0;
const s = endOfDay ? 59 : 0;
const ms = endOfDay ? 999 : 0;
if (!timeZone) {
return new Date(y, m - 1, d, h, min, s, ms).toISOString();
}
const wallClockUtc = Date.UTC(y, m - 1, d, h, min, s, ms);
// Two passes so DST transitions near the boundary converge on the right offset
let ts = wallClockUtc;
for (let i = 0; i < 2; i++) {
ts = wallClockUtc - tzOffsetMs(timeZone, new Date(ts));
}
return new Date(ts).toISOString();
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Syntax-highlight a Dockerfile/shell command for display via {@html}.
*
* The input (e.g. an image layer's CreatedBy) is attacker-controlled in a
* crafted image, so it MUST be HTML-escaped before any markup is inserted —
* otherwise the {@html} render is an XSS sink.
*/
export function highlightCommand(cmd: string): string {
if (!cmd) return '';
// Dockerfile instructions
const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM'];
const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g');
// Shell keywords
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select'];
const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g');
// Common commands
const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash'];
const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g');
// Escape HTML before inserting any markup. The input is attacker-controlled
// (image build history) and rendered via {@html}, so unescaped < > & would be
// an XSS sink.
const highlighted = cmd
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Strings in quotes
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="text-green-600 dark:text-green-400">$1</span>')
// Comments
.replace(/(#[^\n]*)/g, '<span class="text-gray-500 dark:text-gray-400 italic">$1</span>')
// Dockerfile instructions (must be before shell keywords to take precedence)
.replace(dockerInstructionPattern, '<span class="text-blue-600 dark:text-blue-400 font-semibold">$1</span>')
// Shell keywords
.replace(shellKeywordPattern, '<span class="text-purple-600 dark:text-purple-400">$1</span>')
// Commands
.replace(commandPattern, '<span class="text-cyan-600 dark:text-cyan-400">$1</span>')
// Flags (words starting with - or --)
.replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1<span class="text-yellow-600 dark:text-yellow-400">$2</span>');
return highlighted;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Build a schema-valid SARIF 2.1.0 document from Dockhand vulnerability findings,
* for ingestion by DefectDojo / GitHub code scanning / Dependency-Track (#415).
*
* SARIF 2.1.0 is a frozen OASIS standard, so we hand-build the object literal
* against the @types/sarif types rather than take a runtime dependency.
*/
import type { Log, Run, Result, ReportingDescriptor } from 'sarif';
import type { Finding } from '$lib/utils/vulnerability';
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
/** Map a Dockhand severity to a SARIF result level. */
function sarifLevel(severity: string): Result.level {
switch (severity.toLowerCase()) {
case 'critical':
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
case 'negligible':
return 'note';
default:
return 'none';
}
}
/**
* A repo-relative artifact URI for a package inside an image.
*
* SARIF's artifactLocation.uri is a uri-reference; consumers (GitHub code
* scanning especially) resolve it against a base and drop results whose URI is
* an *absolute* URI. An image ref like `nginx:latest` would make `nginx:` parse
* as a URI scheme, so we prefix a constant `images/` segment (guaranteeing the
* first path segment has no colon) and percent-encode each segment.
*/
function artifactUri(imageName: string, pkg: string): string {
return `images/${encodeURIComponent(imageName)}/${encodeURIComponent(pkg)}`;
}
/** GitHub/DefectDojo read `security-severity` (0-10) from rule properties. */
function securitySeverityScore(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical': return '9.5';
case 'high': return '8.0';
case 'medium': return '5.5';
case 'low': return '3.0';
default: return '0.0';
}
}
export interface SarifOptions {
toolName?: string;
toolVersion?: string;
informationUri?: string;
}
/**
* Convert findings into a SARIF log with a single run. Each unique CVE becomes a
* reporting descriptor (rule); each finding becomes a result located at the
* affected package within its image.
*/
export function findingsToSarif(findings: Finding[], opts: SarifOptions = {}): Log {
const toolName = opts.toolName ?? 'Dockhand';
// __APP_VERSION__ is a Vite define (git tag / VERSION file); guard for non-Vite
// contexts such as `bun test`.
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
const toolVersion = opts.toolVersion ?? appVersion ?? 'unknown';
const informationUri = opts.informationUri ?? 'https://github.com/Finsys/dockhand';
// One rule per distinct CVE — dedupe, keeping the most detailed description/link.
const ruleMap = new Map<string, ReportingDescriptor>();
for (const f of findings) {
if (ruleMap.has(f.cve)) continue;
ruleMap.set(f.cve, {
id: f.cve,
name: f.cve,
shortDescription: { text: `${f.cve} in ${f.package}` },
fullDescription: { text: f.description || `${f.cve} affecting ${f.package}` },
helpUri: f.link || `https://nvd.nist.gov/vuln/detail/${f.cve}`,
defaultConfiguration: { level: sarifLevel(f.severity) },
properties: {
// GitHub code scanning / DefectDojo read the hyphenated key off the rule.
'security-severity': securitySeverityScore(f.severity),
tags: ['vulnerability', 'security', f.severity.toLowerCase()]
}
});
}
const results: Result[] = findings.map((f) => {
const fixText = f.fixedVersion ? `Fixed in ${f.fixedVersion}.` : 'No fix available.';
return {
ruleId: f.cve,
level: sarifLevel(f.severity),
message: {
text: `${f.severity.toUpperCase()} ${f.cve} in ${f.package} ${f.installedVersion} (image ${f.imageName}). ${fixText}`
},
locations: [
{
physicalLocation: {
artifactLocation: {
// Repo-relative URI identifying the vulnerable package inside the
// image (see artifactUri: image refs contain colons, which must not
// be read as a URI scheme).
uri: artifactUri(f.imageName, f.package)
}
},
logicalLocations: [
{ name: f.package, kind: 'package', fullyQualifiedName: `${f.imageName}/${f.package}@${f.installedVersion}` }
]
}
],
partialFingerprints: {
// Custom stable fingerprint so re-imports dedupe: image|cve|package|version.
// (Not primaryLocationLineHash — that reserved key has hash semantics.)
dockhandFinding: `${f.imageId}|${f.cve}|${f.package}|${f.installedVersion}`
},
properties: {
imageName: f.imageName,
imageId: f.imageId,
package: f.package,
installedVersion: f.installedVersion,
fixedVersion: f.fixedVersion || null,
severity: f.severity,
scannedAt: f.scannedAt || null,
containers: (f.containers ?? []).map((c) => c.name),
stacks: f.stacks ?? []
}
};
});
const run: Run = {
tool: {
driver: {
name: toolName,
version: toolVersion,
informationUri,
rules: Array.from(ruleMap.values())
}
},
results
};
return {
version: '2.1.0',
$schema: SARIF_SCHEMA,
runs: [run]
};
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Render a third-party template description (from a Portainer catalog) for
* display via {@html}.
*
* The input is untrusted, so: HTML tags are stripped, and markdown links are
* rebuilt only for safe http(s)/relative URLs — javascript:/data:/etc. are
* dropped (keeping just the link text) to prevent a `[x](javascript:...)` XSS.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Only http(s) and relative URLs are safe to put in href; reject javascript:,
// data:, etc.
export function isSafeLinkUrl(url: string): boolean {
// Browsers ignore ASCII whitespace/control chars when parsing the scheme, so
// strip them before checking (defeats java\tscript: style bypasses).
const cleaned = url.replace(/[\x00-\x20]/g, '').toLowerCase();
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) return true;
// No scheme (relative path/anchor) is safe; any other scheme is not.
return !/^[a-z][a-z0-9+.-]*:/.test(cleaned);
}
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML.
export function renderDescription(text: string): string {
return text
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first
.replace(/<[^>]+>/g, '') // strip remaining HTML tags
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
const safeText = escapeHtml(label);
if (!isSafeLinkUrl(url)) return safeText; // drop unsafe scheme, keep the text
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="text-primary hover:underline">${safeText}</a>`;
})
.trim();
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Shared vulnerability/severity helpers used by the per-image scan view and
* the aggregated Vulnerabilities dashboard.
*/
export interface Vulnerability {
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
}
/** Sort order: lower = more severe. Unknown/unmapped sort last. */
export const SEVERITY_ORDER: Record<string, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
negligible: 4,
unknown: 5
};
export function severityRank(severity: string): number {
return SEVERITY_ORDER[severity.toLowerCase()] ?? SEVERITY_ORDER.unknown;
}
/** Tailwind classes for a severity Badge (bg + text + border). */
export function getSeverityColor(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical':
return 'bg-red-500/10 text-red-500 border-red-500/30';
case 'high':
return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
case 'medium':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/30';
case 'low':
return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
case 'negligible':
case 'unknown':
default:
return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
}
}
/** The four severities shown as summary pills, most severe first. */
export const SUMMARY_SEVERITIES = ['critical', 'high', 'medium', 'low'] as const;
export type SummarySeverity = (typeof SUMMARY_SEVERITIES)[number];
/** Title-case a severity label (e.g. "critical" -> "Critical"). */
export function severityLabel(severity: string): string {
return severity.charAt(0).toUpperCase() + severity.slice(1);
}
/** A container reference attached to a finding. */
export interface FindingContainer {
id: string;
name: string;
}
/** A single vulnerability finding as returned by /api/vulnerabilities. */
export interface Finding {
key: string;
cve: string;
package: string;
severity: string;
installedVersion: string;
fixedVersion: string;
imageId: string;
imageName: string;
description?: string;
link?: string;
scannedAt?: string;
containers?: FindingContainer[];
stacks?: string[];
}
/** Aggregate counts for the dashboard summary row / header badge. */
export interface VulnerabilitySummary {
total: number;
critical: number;
high: number;
medium: number;
low: number;
imagesScanned: number;
totalImages: number;
}
/** A zeroed summary — shared so the 7-field shape isn't re-typed per endpoint. */
export const EMPTY_SUMMARY: VulnerabilitySummary = {
total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0
};
/**
* Minimal finding shape needed for filtering. Both the dashboard grid (client)
* and the export endpoint (server) filter against this, so the two can't drift.
*/
export interface FilterableFinding {
cve: string;
package: string;
severity: string;
imageName: string;
containers?: { name: string }[];
stacks?: string[];
}
export interface FindingFilter {
/** Free-text query matched against cve/package/image/container/stack names. */
q?: string;
/** Lowercase severity values; empty = no severity filter. */
severities?: string[];
images?: string[];
containers?: string[];
stacks?: string[];
}
/** A persisted scan row shape (subset used for flattening into findings). */
export interface ScanRow {
imageId: string;
imageName: string;
scannedAt: string;
vulnerabilities: any;
}
/**
* Flatten persisted scan rows into deduped per-CVE findings. Shared by the
* env-wide aggregation and the per-image export so both produce the same shape.
* `enrich` optionally attaches containers/stacks by imageId.
*/
export function flattenScansToFindings(
scans: ScanRow[],
enrich?: {
containersByImage?: Map<string, FindingContainer[]>;
stacksByImage?: Map<string, Set<string>>;
}
): Finding[] {
const seen = new Set<string>();
const findings: Finding[] = [];
for (const scan of scans) {
const containers = enrich?.containersByImage?.get(scan.imageId);
const stacks = enrich?.stacksByImage?.get(scan.imageId);
// Legacy rows were double-JSON-encoded; parse once more if still a string.
let vulns: any[] = scan.vulnerabilities as any;
if (typeof vulns === 'string') {
try { vulns = JSON.parse(vulns); } catch { vulns = []; }
}
if (!Array.isArray(vulns)) vulns = [];
for (const v of vulns) {
const key = `${scan.imageId}|${v.id}|${v.package}|${v.version}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
key,
cve: v.id,
package: v.package,
severity: v.severity,
installedVersion: v.version,
fixedVersion: v.fixedVersion || '',
imageId: scan.imageId,
imageName: scan.imageName,
description: v.description,
link: v.link,
scannedAt: scan.scannedAt,
containers: containers && containers.length ? containers : undefined,
stacks: stacks && stacks.size ? Array.from(stacks) : undefined
});
}
}
return findings;
}
/** Filter findings by the dashboard's search + multi-selects. Pure, shared client/server. */
export function filterFindings<T extends FilterableFinding>(findings: T[], filter: FindingFilter): T[] {
const q = (filter.q ?? '').toLowerCase().trim();
const sevSet = filter.severities?.length ? new Set(filter.severities) : null;
const imgSet = filter.images?.length ? new Set(filter.images) : null;
const containerSet = filter.containers?.length ? new Set(filter.containers) : null;
const stackSet = filter.stacks?.length ? new Set(filter.stacks) : null;
// Fast path: nothing to filter — return the input without copying.
if (!q && !sevSet && !imgSet && !containerSet && !stackSet) return findings;
return findings.filter((f) => {
if (sevSet && !sevSet.has(f.severity.toLowerCase())) return false;
if (imgSet && !imgSet.has(f.imageName)) return false;
if (containerSet && !(f.containers ?? []).some((c) => containerSet.has(c.name))) return false;
if (stackSet && !(f.stacks ?? []).some((s) => stackSet.has(s))) return false;
if (q) {
const containerNames = (f.containers ?? []).map((c) => c.name).join(' ');
const stackNames = (f.stacks ?? []).join(' ');
const hay = `${f.cve} ${f.package} ${f.imageName} ${containerNames} ${stackNames}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export type SortField =
| 'cve' | 'package' | 'severity' | 'image'
| 'installed' | 'fixed' | 'container' | 'stack' | 'scannedAt';
/** The min (alphabetically first) name in a list — used to sort by container/stack. */
function minName(names: string[]): string {
let min: string | undefined;
for (const n of names) if (min === undefined || n < min) min = n;
return min ?? '';
}
/**
* Sort findings by a column. Pure, shared client/server. Returns a new array.
*
* Uses a Schwartzian transform: the sort key for each row is computed ONCE up
* front, not inside the comparator. This matters for the container/stack fields,
* whose key requires reducing a list — doing that per comparison would allocate
* and re-scan O(N log N) times instead of O(N).
*/
export function sortFindings<T extends Finding>(findings: T[], field: SortField, direction: 'asc' | 'desc'): T[] {
const dir = direction === 'asc' ? 1 : -1;
// Numeric key for severity; string key for everything else.
const numericKey = field === 'severity';
const keyFor = (f: T): number | string => {
switch (field) {
case 'severity': return severityRank(f.severity);
case 'cve': return f.cve;
case 'package': return f.package;
case 'image': return f.imageName;
case 'installed': return f.installedVersion;
case 'fixed': return f.fixedVersion || '';
case 'container': return minName((f.containers ?? []).map((c) => c.name));
case 'stack': return minName(f.stacks ?? []);
case 'scannedAt': return f.scannedAt ?? '';
default: return '';
}
};
// Version-ish fields compare numerically within the string (1.2.10 > 1.2.9).
const numericStr = field === 'installed' || field === 'fixed';
const decorated = findings.map((f) => ({ f, key: keyFor(f) }));
decorated.sort((a, b) => {
const cmp = numericKey
? (a.key as number) - (b.key as number)
: (a.key as string).localeCompare(b.key as string, undefined, numericStr ? { numeric: true } : undefined);
return cmp * dir;
});
return decorated.map((d) => d.f);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Generic sliding-window list for server-backed virtual grids.
*
* Holds at most `windowSize` items in memory regardless of the total row count,
* so large datasets don't blow up browser memory. Pairs directly with the
* DataGrid virtual-scroll windowing props:
*
* <DataGrid data={list.items} virtualScroll windowed
* dataOffset={list.offset} virtualTotal={list.total}
* onWindowShift={list.shiftTo} loading={list.loading} />
*
* You supply one `fetchPage(offset, limit, signal)` that returns `{ items, total }`.
* The primitive owns the window offset, abort/generation guarding of superseded
* fetches, and centering the window on the requested scroll position.
*
* The fetch orchestration lives in `WindowedListCore` (plain, unit-testable);
* `createWindowedList` is a thin Svelte-runes adapter over it.
*/
export interface WindowPage<T> {
items: T[];
total: number;
}
/** State snapshot pushed to observers whenever the window changes. */
export interface WindowState<T> {
items: T[];
offset: number;
total: number;
loading: boolean;
loaded: boolean;
}
export interface WindowedListOptions<T> {
/** Fetch one contiguous slice. Must honor `signal` for cancellation. */
fetchPage: (offset: number, limit: number, signal: AbortSignal) => Promise<WindowPage<T>>;
/** Rows kept in memory at once (default 400). */
windowSize?: number;
/** Called on fetch error (non-abort). */
onError?: (error: unknown) => void;
}
/**
* The absolute offset at which to start a window so it's centered on `targetStart`,
* clamped to [0, ...). Pure — unit-tested independently of the reactive factory.
*/
export function windowOffsetFor(targetStart: number, windowSize: number): number {
return Math.max(0, targetStart - Math.floor(windowSize / 2));
}
/**
* Framework-agnostic core: owns the window offset, abort/generation guarding, and
* centering. Pushes a fresh WindowState to `onState` whenever anything changes.
* No Svelte runes, so it's fully unit-testable.
*/
export class WindowedListCore<T> {
private windowSize: number;
private controller: AbortController | null = null;
private generation = 0;
/** Absolute offset of the currently in-flight fetch, or null when idle. */
private inflightDesired: number | null = null;
items: T[] = [];
offset = 0;
total = 0;
loading = false;
loaded = false;
constructor(
private options: WindowedListOptions<T>,
private onState: (s: WindowState<T>) => void = () => {}
) {
this.windowSize = options.windowSize ?? 400;
}
private emit() {
this.onState({
items: this.items,
offset: this.offset,
total: this.total,
loading: this.loading,
loaded: this.loaded
});
}
/**
* Shift/center the window on an absolute row index.
*
* `force` bypasses the same-offset dedupe: a scroll fling emits shiftTo per
* frame and we skip an identical in-flight offset (fling case), but a
* data-invalidating reset (filter/sort change) maps to the SAME offset while
* the underlying dataset differs, so it must NOT be deduped — it aborts the
* stale in-flight fetch and refetches with the new query.
*/
async shiftTo(targetStart: number, force = false): Promise<void> {
const desired = windowOffsetFor(targetStart, this.windowSize);
// A fling emits shiftTo per scroll frame; without this every frame would
// abort the in-flight request and start an identical one. If the same
// offset is already loading, let it finish instead of thrashing the network.
// `force` skips this so a reset refetches even at the same offset.
if (!force && this.loading && desired === this.inflightDesired) return;
const gen = ++this.generation;
this.controller?.abort();
this.controller = new AbortController();
this.loading = true;
this.inflightDesired = desired;
this.emit();
try {
const page = await this.options.fetchPage(desired, this.windowSize, this.controller.signal);
if (gen !== this.generation) return; // superseded by a newer fetch
this.total = page.total;
this.items = page.items;
this.offset = desired;
this.loaded = true;
} catch (error) {
if ((error as Error)?.name !== 'AbortError') this.options.onError?.(error);
} finally {
if (gen === this.generation) {
this.loading = false;
this.inflightDesired = null;
this.emit();
}
}
}
/** Reset to the first window (filter/sort/dataset change). Forces a refetch
* even if an offset-0 load is already in flight for the previous query. */
reset(): Promise<void> {
return this.shiftTo(0, true);
}
/** Clear all state without fetching. */
clear(): void {
this.controller?.abort();
this.generation++;
this.items = [];
this.offset = 0;
this.total = 0;
this.loaded = false;
this.loading = false;
this.inflightDesired = null;
this.emit();
}
}
export interface WindowedList<T> {
readonly items: T[];
readonly offset: number;
readonly total: number;
readonly loading: boolean;
readonly loaded: boolean;
shiftTo: (targetStart: number) => void;
reset: () => void;
clear: () => void;
}
/** Svelte-runes adapter over WindowedListCore — the reactive state a component binds to. */
export function createWindowedList<T>(options: WindowedListOptions<T>): WindowedList<T> {
let items = $state<T[]>([]);
let offset = $state(0);
let total = $state(0);
let loading = $state(false);
let loaded = $state(false);
const core = new WindowedListCore<T>(options, (s) => {
items = s.items;
offset = s.offset;
total = s.total;
loading = s.loading;
loaded = s.loaded;
});
return {
get items() { return items; },
get offset() { return offset; },
get total() { return total; },
get loading() { return loading; },
get loaded() { return loaded; },
shiftTo: (targetStart: number) => { void core.shiftTo(targetStart); },
reset: () => { void core.reset(); },
clear: () => core.clear()
};
}