fix ui lag

This commit is contained in:
lekss361 2026-04-27 20:26:55 +03:00
parent c079ac2a77
commit b3c15bb8ea
17 changed files with 473 additions and 72 deletions

View file

@ -69,17 +69,65 @@ def trigger_kn_sweep(
@router.get("/queue")
def queue_status(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
"""Snapshot of Celery worker(s) state: active tasks, reserved (queued on
worker, not yet started), scheduled (with ETA), beat-schedule, and queue
depth in Redis. If no worker is running, all worker-side fields are empty.
"""Snapshot of Celery state, optimised for UI poll latency.
- active: from kn_scrape_runs WHERE status='running' (instant, richer info
than celery inspect includes objects_count/flats_count/requests_count).
- reserved + workers: celery inspect with short timeout (500 ms), called in
a thread so a missing worker can't block the request.
- queue_depth: Redis LLEN on default queue (single round-trip).
- scheduled: omitted we don't use ETA tasks.
Worst-case latency 600 ms even if no worker is reachable, vs ~8 s with
the previous 4×2 s blocking inspect calls.
"""
_check_token(x_admin_token)
import concurrent.futures
import time
from app.workers.celery_app import celery_app
inspect = celery_app.control.inspect(timeout=2.0)
# 1) Active runs straight from DB — no broker round-trip.
active_rows = (
db.execute(
text(
"""
SELECT run_id, started_at, region_codes, developer_ids,
objects_count, flats_count, requests_count
FROM kn_scrape_runs
WHERE status = 'running'
ORDER BY started_at DESC
LIMIT 20
"""
)
)
.mappings()
.all()
)
active: list[dict[str, Any]] = [
{
"worker": "db",
"task_id": f"run-{r['run_id']}",
"name": "scrape_kn_region",
"args": [list(r["region_codes"] or []), list(r["developer_ids"] or [])],
"kwargs": {
"objects": r["objects_count"],
"flats": r["flats_count"],
"requests": r["requests_count"],
},
"time_start": (int(r["started_at"].timestamp()) if r["started_at"] else None),
"eta": None,
}
for r in active_rows
]
# 2) Run reserved + ping in parallel with a tight timeout so the endpoint
# never hangs when the broker is unreachable or worker is gone.
inspect = celery_app.control.inspect(timeout=0.5)
def _flatten(per_worker: dict[str, list[dict[str, Any]]] | None) -> list[dict[str, Any]]:
if not per_worker:
@ -100,42 +148,43 @@ def queue_status(
)
return out
try:
active = _flatten(inspect.active())
except Exception:
active = []
try:
reserved = _flatten(inspect.reserved())
except Exception:
reserved = []
try:
scheduled = _flatten(inspect.scheduled())
except Exception:
scheduled = []
def _safe(fn):
try:
return fn()
except Exception:
return None
# Pending in broker queue (not yet picked up by any worker).
deadline = time.monotonic() + 0.8
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
f_reserved = ex.submit(_safe, inspect.reserved)
f_ping = ex.submit(_safe, inspect.ping)
try:
reserved_raw = f_reserved.result(timeout=max(0.1, deadline - time.monotonic()))
except concurrent.futures.TimeoutError:
reserved_raw = None
try:
ping_resp = f_ping.result(timeout=max(0.1, deadline - time.monotonic()))
except concurrent.futures.TimeoutError:
ping_resp = None
reserved = _flatten(reserved_raw)
workers = list((ping_resp or {}).keys())
# 3) Pending in broker queue (not yet picked up by any worker).
queue_depth: int | None = None
try:
with celery_app.connection_or_acquire() as conn:
with conn.channel() as channel:
# Default queue name is 'celery'.
queue_depth = channel.client.llen("celery")
except Exception:
queue_depth = None
workers: list[str] = []
try:
ping_resp = inspect.ping() or {}
workers = list(ping_resp.keys())
except Exception:
pass
return {
"workers": workers,
"queue_depth": queue_depth,
"active": active,
"reserved": reserved,
"scheduled": scheduled,
"scheduled": [],
}

View file

@ -0,0 +1,87 @@
"""Serve cached DOM.РФ photos.
GET /api/v1/photos/{obj_id}/{file_id}?size=thumb|full
- size=thumb (default): WebP 320×240 from local cache. If thumb is missing
but the original is present locally, generate one on the fly. If neither
is present, redirect to the upstream DOM.РФ URL.
- size=full: original PNG/JPG if cached locally, else redirect upstream.
This isolates the frontend grid from upstream latency and from Next.js dev-mode
optimizer cold-hit cost.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, RedirectResponse, Response
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services.photos.thumbs import make_thumbnail
logger = logging.getLogger(__name__)
router = APIRouter()
_PHOTO_LOOKUP_SQL = text(
"""
SELECT local_path, thumb_path, photo_url
FROM domrf_kn_photos
WHERE obj_id = :obj AND obj_file_id = :fid
LIMIT 1
"""
)
@router.get("/{obj_id}/{file_id}")
def get_photo(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
file_id: str,
size: Annotated[Literal["thumb", "full"], Query()] = "thumb",
) -> Response:
row = db.execute(_PHOTO_LOOKUP_SQL, {"obj": obj_id, "fid": file_id}).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="photo not registered")
local_path = row["local_path"]
thumb_path = row["thumb_path"]
upstream = row["photo_url"]
headers = {"Cache-Control": "public, max-age=604800, immutable"}
if size == "full":
if local_path and Path(local_path).exists():
return FileResponse(local_path, headers=headers)
if upstream:
return RedirectResponse(upstream, status_code=302)
raise HTTPException(status_code=404, detail="full photo unavailable")
# size=thumb
if thumb_path and Path(thumb_path).exists():
return FileResponse(thumb_path, media_type="image/webp", headers=headers)
# Generate thumb on the fly from cached original
if local_path and Path(local_path).exists():
generated = make_thumbnail(Path(local_path))
if generated and generated.exists():
db.execute(
text(
"UPDATE domrf_kn_photos SET thumb_path = :tp"
" WHERE obj_id = :o AND obj_file_id = :f"
),
{"tp": str(generated), "o": obj_id, "f": file_id},
)
db.commit()
return FileResponse(str(generated), media_type="image/webp", headers=headers)
# Last resort — redirect to upstream so the page still renders
if upstream:
return RedirectResponse(upstream, status_code=302)
raise HTTPException(status_code=404, detail="photo unavailable")

View file

@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import admin_leads, admin_scrape, analytics, concepts, parcels
from app.api.v1 import admin_leads, admin_scrape, analytics, concepts, parcels, photos
from app.core.config import settings
@ -35,6 +35,7 @@ app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
@app.get("/health")

View file

@ -787,6 +787,10 @@ def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str,
"obj_file_id": r["obj_file_id"],
"ord_num": r["ord_num"],
"photo_url": r["photo_url"],
# Always serve thumbs through our backend — cached WebP, no upstream
# latency, no Next.js dev-mode optimizer cold-hit cost.
"thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb",
"full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full",
"photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
"period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
"size_bytes": r["size_bytes"],

View file

View file

@ -0,0 +1,57 @@
"""Thumbnail generation for DOM.РФ photos.
Strategy: original PNG/JPG ~1-3 MB downloaded by scraper to
data/raw/domrf_photos/<obj_id>/<file_id>.{png,jpg}. We additionally write a
WebP cover-thumbnail at 320×240 (~10-30 KB) next to it, suffix `_thumb.webp`.
The frontend grid uses /api/v1/photos/<obj_id>/<file_id>?size=thumb. Lightbox
opens the original DOM.РФ URL (we don't need to mirror originals).
"""
from __future__ import annotations
import logging
from pathlib import Path
from PIL import Image, ImageOps
logger = logging.getLogger(__name__)
THUMB_SIZE: tuple[int, int] = (320, 240)
THUMB_QUALITY: int = 70
THUMB_SUFFIX: str = "_thumb.webp"
def thumb_path_for(src: Path) -> Path:
"""Return canonical thumbnail path next to the source file."""
return src.with_name(src.stem + THUMB_SUFFIX)
def make_thumbnail(
src: Path,
*,
size: tuple[int, int] = THUMB_SIZE,
quality: int = THUMB_QUALITY,
overwrite: bool = False,
) -> Path | None:
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
Skips if thumb already exists and overwrite=False. Returns None on any error
(logged) so callers can keep going with the next file.
"""
if not src.exists():
return None
dst = thumb_path_for(src)
if dst.exists() and not overwrite:
return dst
try:
with Image.open(src) as im:
im = ImageOps.exif_transpose(im)
im = im.convert("RGB")
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
dst.parent.mkdir(parents=True, exist_ok=True)
im.save(dst, format="WEBP", quality=quality, method=4)
return dst
except Exception as e:
logger.warning("thumbnail %s failed: %s", src, e)
return None

View file

@ -429,11 +429,11 @@ UPSERT_PHOTO_SQL = text(
INSERT INTO domrf_kn_photos (
obj_id, obj_file_id, ord_num, photo_url, photo_dttm, period_dt,
size_bytes, photo_name, ready_desc, build_type, hidden,
local_path, downloaded_at
local_path, thumb_path, downloaded_at
) VALUES (
:obj_id, :obj_file_id, :ord_num, :photo_url, :photo_dttm, :period_dt,
:size_bytes, :photo_name, :ready_desc, :build_type, :hidden,
:local_path, :downloaded_at
:local_path, :thumb_path, :downloaded_at
)
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
ord_num = EXCLUDED.ord_num,
@ -445,6 +445,7 @@ UPSERT_PHOTO_SQL = text(
ready_desc = EXCLUDED.ready_desc,
hidden = EXCLUDED.hidden,
local_path = COALESCE(EXCLUDED.local_path, domrf_kn_photos.local_path),
thumb_path = COALESCE(EXCLUDED.thumb_path, domrf_kn_photos.thumb_path),
downloaded_at = COALESCE(EXCLUDED.downloaded_at, domrf_kn_photos.downloaded_at)
"""
)
@ -699,9 +700,11 @@ def upsert_photos(
obj_id: int,
photos: list[dict[str, Any]],
local_paths: dict[str, str] | None = None,
thumb_paths: dict[str, str] | None = None,
) -> int:
"""Upsert photo metadata. local_paths maps file_id → relative path on disk."""
"""Upsert photo metadata. local_paths/thumb_paths map file_id → path."""
local_paths = local_paths or {}
thumb_paths = thumb_paths or {}
now_dt = datetime.now()
n = 0
for p in photos:
@ -709,6 +712,7 @@ def upsert_photos(
if not file_id:
continue
local = local_paths.get(file_id)
thumb = thumb_paths.get(file_id)
try:
db.execute(
UPSERT_PHOTO_SQL,
@ -725,6 +729,7 @@ def upsert_photos(
"build_type": p.get("objBuildTypeShortDesc"),
"hidden": _to_bool(p.get("objPhotoHiddenFlg")),
"local_path": local,
"thumb_path": thumb,
"downloaded_at": now_dt if local else None,
},
)
@ -740,12 +745,18 @@ async def download_photos(
obj_id: int,
photos: list[dict[str, Any]],
base_dir: Path,
) -> dict[str, str]:
"""Download photo binaries to base_dir/{obj_id}/{file_id}.png (or .jpg).
Returns mapping {file_id: relative_path}. Skips if file already exists with
matching size_bytes.
) -> tuple[dict[str, str], dict[str, str]]:
"""Download photo binaries to base_dir/{obj_id}/{file_id}.png (or .jpg) +
generate WebP thumbnails next to them.
Returns a pair of mappings: (local_paths, thumb_paths) both keyed by
file_id, value = relative path on disk. Skips originals already present
with matching size_bytes; thumbs skipped if already exist.
"""
out: dict[str, str] = {}
from app.services.photos.thumbs import make_thumbnail, thumb_path_for
locals_out: dict[str, str] = {}
thumbs_out: dict[str, str] = {}
obj_dir = base_dir / str(obj_id)
obj_dir.mkdir(parents=True, exist_ok=True)
for p in photos:
@ -760,15 +771,24 @@ async def download_photos(
# Skip if size matches
expected_size = p.get("objSize")
if local.exists() and expected_size and local.stat().st_size == expected_size:
out[file_id] = str(local)
continue
try:
data = await sess.download_binary(url)
local.write_bytes(data)
out[file_id] = str(local)
except Exception as e:
logger.warning("download photo obj=%s file=%s failed: %s", obj_id, file_id, e)
return out
locals_out[file_id] = str(local)
else:
try:
data = await sess.download_binary(url)
local.write_bytes(data)
locals_out[file_id] = str(local)
except Exception as e:
logger.warning("download photo obj=%s file=%s failed: %s", obj_id, file_id, e)
continue
# Always (re)check thumbnail. make_thumbnail is no-op if it already exists.
existing_thumb = thumb_path_for(local)
if existing_thumb.exists():
thumbs_out[file_id] = str(existing_thumb)
else:
t = make_thumbnail(local)
if t:
thumbs_out[file_id] = str(t)
return locals_out, thumbs_out
# ── orchestrator ─────────────────────────────────────────────────────────────
@ -999,11 +1019,14 @@ async def run_region_sweep(
try:
photos, full_url = await fetch_photos(sess, obj_id)
local_paths: dict[str, str] = {}
thumb_paths: dict[str, str] = {}
if download_photos_binary and photos:
local_paths = await download_photos(sess, obj_id, photos, pdir)
local_paths, thumb_paths = await download_photos(
sess, obj_id, photos, pdir
)
extras_counts["photos_downloaded"] += len(local_paths)
extras_counts["photos_rows"] += upsert_photos(
db, obj_id, photos, local_paths
db, obj_id, photos, local_paths, thumb_paths
)
except Exception as e:
full_url = f"{BASE_URL}{PATH_PHOTOS.format(obj_id=obj_id)}"

View file

@ -20,6 +20,7 @@ dependencies = [
"celery>=5.4.0",
"playwright>=1.45.0",
"tenacity>=9.0.0",
"pillow>=10.4.0",
"weasyprint>=62.0",
"ezdxf>=1.3.0",
"openpyxl>=3.1.0",

View file

@ -0,0 +1,10 @@
-- Add thumbnail path column for cached locally-served thumbnails.
-- Generated by Pillow at download time (320×240 cover, WebP q=70).
-- Idempotent.
ALTER TABLE domrf_kn_photos
ADD COLUMN IF NOT EXISTS thumb_path TEXT;
CREATE INDEX IF NOT EXISTS idx_kn_photos_obj_visible
ON domrf_kn_photos (obj_id)
WHERE COALESCE(hidden, FALSE) = FALSE;

View file

@ -388,6 +388,24 @@ export default function ScrapeAdminPage() {
</p>
) : queue.data ? (
<>
{queue.data.active.length === 0 &&
queue.data.reserved.length === 0 &&
queue.data.scheduled.length === 0 ? (
<div
style={{
padding: 12,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 6,
color: "#065f46",
fontSize: 13,
}}
>
Worker онлайн ({queue.data.workers.join(", ")}) и ждёт задач.
Активных нет, очередь пуста. Нажми «Запустить сейчас» выше или
подожди расписание (cron <code>SCRAPE_KN_CRON</code>).
</div>
) : null}
{queue.data.active.length > 0 ? (
<>
<h3

View file

@ -1,15 +1,66 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { KpiCard } from "@/components/analytics/KpiCard";
import { ObjectInfraMap } from "@/components/analytics/ObjectInfraMap";
import { ObjectPhotoGallery } from "@/components/analytics/ObjectPhotoGallery";
import { ObjectSaleChart } from "@/components/analytics/ObjectSaleChart";
import { LazyMount } from "@/components/analytics/LazyMount";
import { Section } from "@/components/analytics/Section";
import { useObjectDetail, useObjectSalesAgg } from "@/lib/analytics-api";
// Heavy sections deferred so initial paint and main-thread are not blocked.
const ObjectSaleChart = dynamic(
() =>
import("@/components/analytics/ObjectSaleChart").then(
(m) => m.ObjectSaleChart,
),
{
ssr: false,
loading: () => (
<div
style={{
height: 300,
background: "#fafbfc",
border: "1px solid #eef0f3",
borderRadius: 8,
}}
/>
),
},
);
const ObjectInfraMap = dynamic(
() =>
import("@/components/analytics/ObjectInfraMap").then(
(m) => m.ObjectInfraMap,
),
{
ssr: false,
loading: () => (
<div
style={{
height: 360,
background: "#fafbfc",
border: "1px solid #eef0f3",
borderRadius: 8,
}}
/>
),
},
);
const ObjectPhotoGallery = dynamic(
() =>
import("@/components/analytics/ObjectPhotoGallery").then(
(m) => m.ObjectPhotoGallery,
),
{
ssr: false,
loading: () => <div style={{ height: 320 }} />,
},
);
export default function ObjectDrillInPage() {
const params = useParams<{ id: string }>();
const objId = params.id;
@ -120,25 +171,31 @@ export default function ObjectDrillInPage() {
title="Динамика продаж"
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
>
<ObjectSaleChart objId={objId} />
<LazyMount eager minHeight={300}>
<ObjectSaleChart objId={objId} />
</LazyMount>
</Section>
<Section
title="Инфраструктура (POI вокруг ЖК)"
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
>
<ObjectInfraMap
objId={objId}
centerLat={d?.latitude ?? null}
centerLon={d?.longitude ?? null}
/>
<LazyMount minHeight={360} rootMargin="400px">
<ObjectInfraMap
objId={objId}
centerLat={d?.latitude ?? null}
centerLon={d?.longitude ?? null}
/>
</LazyMount>
</Section>
<Section
title="Фото прогресса строительства"
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
>
<ObjectPhotoGallery objId={objId} />
<LazyMount minHeight={320} rootMargin="500px">
<ObjectPhotoGallery objId={objId} />
</LazyMount>
</Section>
</>
);

View file

@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Props {
children: React.ReactNode;
/** Pixel offset for rootMargin so we mount slightly before scroll arrives. */
rootMargin?: string;
/** Reserved height while not yet mounted, prevents CLS. */
minHeight?: number;
/** If true, render immediately without waiting for intersection. */
eager?: boolean;
}
export function LazyMount({
children,
rootMargin = "300px",
minHeight = 320,
eager = false,
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(eager);
useEffect(() => {
if (visible) return;
if (typeof IntersectionObserver === "undefined") {
setVisible(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
setVisible(true);
io.disconnect();
break;
}
}
},
{ rootMargin },
);
io.observe(el);
return () => io.disconnect();
}, [visible, rootMargin]);
return (
<div ref={ref} style={{ minHeight: visible ? undefined : minHeight }}>
{visible ? children : null}
</div>
);
}

View file

@ -86,7 +86,18 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
</select>
</div>
{isLoading ? (
<div style={{ padding: 24, color: "#5b6066" }}>Загрузка POI</div>
<div
style={{
padding: 24,
color: "#5b6066",
minHeight: 320,
background: "#fafbfc",
border: "1px solid #eef0f3",
borderRadius: 8,
}}
>
Загрузка POI
</div>
) : (
<div
style={{

View file

@ -1,6 +1,5 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useObjectPhotos } from "@/lib/analytics-api";
@ -22,12 +21,36 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
}, [active]);
if (isLoading) {
return <div style={{ color: "#5b6066" }}>Загрузка фото</div>;
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 8,
minHeight: 320,
}}
>
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
style={{
aspectRatio: "4/3",
background: "#f3f4f6",
borderRadius: 6,
animation: "pulse 1.4s ease-in-out infinite",
}}
/>
))}
<style>{`@keyframes pulse {0%,100%{opacity:1}50%{opacity:.55}}`}</style>
</div>
);
}
const photos = (data ?? []).filter((p) => p.photo_url);
if (photos.length === 0) {
return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>;
return (
<div style={{ color: "#9ca3af", minHeight: 80 }}>Фото отсутствуют.</div>
);
}
const visible = showAll ? photos : photos.slice(0, INITIAL_LIMIT);
@ -51,7 +74,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
gap: 8,
}}
>
{visible.map((p) => (
{visible.map((p, i) => (
<button
key={p.obj_file_id}
onClick={() => setActive(p.obj_file_id)}
@ -66,15 +89,19 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
position: "relative",
}}
>
<Image
src={p.photo_url!}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.thumb_url}
alt={p.photo_name ?? "фото прогресса"}
fill
sizes="160px"
quality={60}
style={{ objectFit: "cover" }}
loading="lazy"
unoptimized={false}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
position: "absolute",
inset: 0,
}}
/>
<div
style={{
@ -129,7 +156,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activePhoto.photo_url!}
src={activePhoto.full_url}
alt={activePhoto.photo_name ?? ""}
style={{
maxWidth: "92vw",

View file

@ -1,6 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
@ -194,6 +194,7 @@ export function useObjectInfrastructure(
);
},
enabled: !!objId,
placeholderData: keepPreviousData,
});
}

View file

@ -183,6 +183,8 @@ export interface ObjectPhoto {
obj_file_id: string;
ord_num: number | null;
photo_url: string | null;
thumb_url: string;
full_url: string;
photo_dttm: string | null;
period_dt: string | null;
size_bytes: number | null;

File diff suppressed because one or more lines are too long