Initial commit
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# `.agents/` — Subagent Briefs
|
||||
|
||||
This directory contains one Markdown brief per subagent referenced by `PLAN.md`. Each brief defines:
|
||||
- the **assigned LLM model** to use,
|
||||
- the subagent’s **mission**,
|
||||
- responsibilities and guardrails,
|
||||
- expected **deliverables** and **definition of done**.
|
||||
|
||||
These files are intended to be used by a human or an orchestration tool that can spawn specialized agents per task.
|
||||
|
||||
---
|
||||
|
||||
## Agent roster
|
||||
|
||||
| Agent | Model | File |
|
||||
|---|---|---|
|
||||
| `orchestrator` | `github-copilot/gpt-5.2` | `./.agents/orchestrator.md` |
|
||||
| `backend-api` | `github-copilot/claude-sonnet-4.5` | `./.agents/backend-api.md` |
|
||||
| `worker-media` | `github-copilot/claude-sonnet-4.5` | `./.agents/worker-media.md` |
|
||||
| `frontend-ui` | `github-copilot/gpt-5.2` | `./.agents/frontend-ui.md` |
|
||||
| `k8s-infra` | `github-copilot/claude-sonnet-4.5` | `./.agents/k8s-infra.md` |
|
||||
| `qa-review` | `github-copilot/claude-haiku-4.5` | `./.agents/qa-review.md` |
|
||||
|
||||
---
|
||||
|
||||
## How to use
|
||||
|
||||
### 1) Pick tasks from `PLAN.md`
|
||||
In `PLAN.md`, use the **Task breakdown (MVP)** section as the source of truth for what needs to be done.
|
||||
|
||||
### 2) Assign each task to the appropriate agent
|
||||
- API/DB/presigning routes → `backend-api`
|
||||
- Media processing and derived generation → `worker-media`
|
||||
- UI tree + responsive layout → `frontend-ui`
|
||||
- Helm/manifests/affinity/Tailscale ingress → `k8s-infra`
|
||||
- Test plan + edge cases review → `qa-review`
|
||||
- Cross-team coordination, contract alignment → `orchestrator`
|
||||
|
||||
### 3) Run agents in parallel where safe
|
||||
Good parallel splits:
|
||||
- `backend-api` and `worker-media` can implement in parallel if they agree on DB/job payloads.
|
||||
- `frontend-ui` can proceed in parallel once API response shapes are agreed.
|
||||
- `k8s-infra` can proceed in parallel once container ports/env vars are known.
|
||||
|
||||
### 4) Maintain contracts and avoid drift
|
||||
Before an agent starts, the `orchestrator` should confirm:
|
||||
- DB schema fields and enums
|
||||
- MinIO key conventions
|
||||
- API request/response shapes
|
||||
- Worker job payloads and retry/idempotency rules
|
||||
|
||||
### 5) Update `PLAN.md` when decisions change
|
||||
If any “Locked” decision changes (endpoints, prefixes, storage policy), update `PLAN.md` first, then propagate to agents.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Never delete/mutate** external archive objects under `originals/`.
|
||||
- Presigned URLs returned to browsers must use **tailnet HTTPS** on `minio.<tailnet-fqdn>` to avoid mixed-content blocking.
|
||||
- Prefer Pi-friendly defaults: low worker concurrency, avoid scheduling heavy pods on Pi 3.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent: backend-api
|
||||
|
||||
**Model:** `github-copilot/claude-sonnet-4.5`
|
||||
|
||||
## Mission
|
||||
Implement the web/API layer: ingestion endpoints, timeline aggregation endpoints, presigned URL generation, and DB schema/migrations.
|
||||
|
||||
## Primary Responsibilities
|
||||
- Database schema:
|
||||
- Implement `assets` and `imports` tables per `PLAN.md`.
|
||||
- Add indexes and constraints.
|
||||
- API endpoints (Next.js API routes or a dedicated Node API):
|
||||
- Imports: create import, upload, scan-minio, status.
|
||||
- Timeline aggregation: `GET /api/tree`.
|
||||
- Asset listing: `GET /api/assets`.
|
||||
- Presigned URLs: `GET /api/assets/:id/url?...`.
|
||||
- MinIO client strategy:
|
||||
- Internal endpoint: `http://minio:9000` for server-side operations.
|
||||
- Public signing endpoint: `https://minio.<tailnet-fqdn>` for presigned URLs.
|
||||
- Use **path-style** URLs.
|
||||
- Enforce safety rules:
|
||||
- Allowlisted scan prefix: `originals/` only.
|
||||
- Never delete/mutate external originals.
|
||||
|
||||
## Inputs
|
||||
- `PLAN.md` (schemas, API list, invariants)
|
||||
- Env vars:
|
||||
- `DATABASE_URL`, `REDIS_URL`
|
||||
- `MINIO_INTERNAL_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT_TS`
|
||||
- MinIO credentials and bucket name
|
||||
|
||||
## Outputs / Deliverables
|
||||
- Migrations and DB access layer.
|
||||
- API route implementations with validated inputs.
|
||||
- OpenAPI-like inline docs (lightweight) or route docs in code.
|
||||
|
||||
## Implementation Notes
|
||||
- Be strict about request validation (zod or equivalent).
|
||||
- Keep request handlers streaming-friendly for uploads.
|
||||
- Ensure presigned URLs are generated for the tailnet endpoint to avoid mixed-content blocks.
|
||||
|
||||
## Error Handling Requirements
|
||||
- Failed ingestion/scan should fail gracefully with actionable messages.
|
||||
- Timeline endpoints must not break if some assets are `failed`.
|
||||
|
||||
## Definition of Done
|
||||
- Manual smoke tests cover upload → process → timeline.
|
||||
- Presigned URLs load media from `https://minio.<tailnet-fqdn>`.
|
||||
- Pagination works for `GET /api/assets`.
|
||||
- Aggregations return correct counts for year/month/day.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent: frontend-ui
|
||||
|
||||
**Model:** `github-copilot/gpt-5.2`
|
||||
|
||||
## Mission
|
||||
Build the dynamic, responsive, mobile-friendly UI: interactive timeline tree, browsing panels, viewer modal, and polished styling.
|
||||
|
||||
## Primary Responsibilities
|
||||
- Timeline tree visualization:
|
||||
- Year → Month → Day nodes.
|
||||
- Expand/collapse.
|
||||
- Orientation toggle (vertical/horizontal).
|
||||
- Zoom/pan (touch + mouse).
|
||||
- Responsive layout:
|
||||
- Desktop: right-side inspector panel.
|
||||
- Mobile: bottom sheet.
|
||||
- Asset gallery:
|
||||
- Virtualized list/grid for thumbnails.
|
||||
- Placeholder tiles for failed assets.
|
||||
- Media viewer:
|
||||
- Images: display from presigned URLs.
|
||||
- Videos: HTML5 `<video>` from presigned URL; show poster and error message when unsupported.
|
||||
- Admin UI:
|
||||
- Upload modal/page.
|
||||
- Scan trigger and import progress display.
|
||||
|
||||
## Inputs
|
||||
- API contract from backend (`/api/tree`, `/api/assets`, `/api/assets/:id/url`).
|
||||
- Design tokens/colors and any brand guidelines (if provided).
|
||||
|
||||
## Outputs / Deliverables
|
||||
- Page routes (`/`, `/admin`).
|
||||
- Timeline tree component + supporting hooks.
|
||||
- Responsive panels and viewer modal.
|
||||
|
||||
## UX Requirements
|
||||
- Must work well on mobile browsers.
|
||||
- Must not crash on partial failures.
|
||||
- Provide loading skeletons for timeline and thumbnails.
|
||||
|
||||
## Performance Requirements
|
||||
- Virtualize thumbnail panel.
|
||||
- Memoize tree layout/render as much as practical.
|
||||
|
||||
## Definition of Done
|
||||
- Timeline renders, expands/collapses, and flips orientation with stable state.
|
||||
- Touch pan/zoom works.
|
||||
- Viewer works for images and for playable videos.
|
||||
- Failed/unsupported items render as placeholders without breaking flows.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent: k8s-infra
|
||||
|
||||
**Model:** `github-copilot/claude-sonnet-4.5`
|
||||
|
||||
## Mission
|
||||
Define and implement Kubernetes deployment artifacts for a Pi-based cluster with Longhorn, in-cluster MinIO, Redis/Postgres, and Tailscale ingress exposure.
|
||||
|
||||
## Primary Responsibilities
|
||||
- Author Helm chart (preferred) or Kustomize manifests for:
|
||||
- `web` Deployment + Service
|
||||
- `worker` Deployment
|
||||
- `redis` Deployment
|
||||
- `postgres` StatefulSet + PVC (Longhorn)
|
||||
- `minio` StatefulSet + PVC (Longhorn) in single-node mode
|
||||
- CronJobs (at least `cleanup-staging`)
|
||||
- Scheduling constraints:
|
||||
- Pin heavy workloads to Pi 5 nodes using labels/affinity.
|
||||
- Keep Pi 3 node unused for this app.
|
||||
- Tailscale ingress resources:
|
||||
- `app.<tailnet-fqdn>`
|
||||
- `minio.<tailnet-fqdn>`
|
||||
- `minio-console.<tailnet-fqdn>`
|
||||
- Nginx ingress (optional LAN): provide values but keep tailnet as primary.
|
||||
|
||||
## Inputs
|
||||
- Cluster facts:
|
||||
- 2× Pi 5 8GB, 1× Pi 3 1GB
|
||||
- Longhorn for PVC
|
||||
- Insecure HTTP in-cluster registry
|
||||
- Tailscale operator already deployed
|
||||
- Service ports:
|
||||
- MinIO S3: 9000
|
||||
- MinIO console: 9001
|
||||
|
||||
## Outputs / Deliverables
|
||||
- Deployable artifacts:
|
||||
- `helm/` chart or `kustomize/` overlays
|
||||
- values/examples for tailnet FQDN configuration
|
||||
- Resource presets (requests/limits) sized for Pi hardware.
|
||||
|
||||
## Operational Requirements
|
||||
- Ensure MinIO is reachable from tailnet clients for presigned URLs.
|
||||
- Preserve Range requests for video playback.
|
||||
- Provide env var plumbing for internal vs public MinIO endpoints.
|
||||
|
||||
## Definition of Done
|
||||
- `helm install` (or equivalent) brings up all services on Pi 5 nodes.
|
||||
- App and MinIO endpoints reachable via tailnet.
|
||||
- PVCs created via Longhorn.
|
||||
- CronJob cleanup runs and is safe (staging-only).
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent: orchestrator
|
||||
|
||||
**Model:** `github-copilot/gpt-5.2`
|
||||
|
||||
## Mission
|
||||
Own planning, integration, and handoffs across all workstreams. Keep the system coherent: interfaces, acceptance criteria, sequencing, and risk management.
|
||||
|
||||
## Primary Responsibilities
|
||||
- Maintain the canonical backlog derived from `PLAN.md`.
|
||||
- Define and validate contracts between components:
|
||||
- API endpoints and payloads.
|
||||
- DB schema entities and invariants.
|
||||
- MinIO object key conventions.
|
||||
- Worker job payloads and retry/idempotency rules.
|
||||
- Ensure Pi-cluster constraints are respected (no heavy workloads on Pi 3).
|
||||
- Track and clearly separate MVP scope vs future features.
|
||||
|
||||
## Inputs
|
||||
- `PLAN.md`
|
||||
- Updated decisions from stakeholders.
|
||||
- PRDs / diagrams / sample media sets (if provided).
|
||||
|
||||
## Outputs / Deliverables
|
||||
- A prioritized task list with acceptance criteria per task.
|
||||
- Interface specs (API/DB/job schemas) signed off by owners.
|
||||
- Integration checklist and release checklist.
|
||||
- Decisions log (if needed) capturing changes to locked assumptions.
|
||||
|
||||
## Coordination Protocol
|
||||
- Before implementation begins, request each subagent to confirm:
|
||||
- inputs required,
|
||||
- assumptions,
|
||||
- outputs, and
|
||||
- test strategy.
|
||||
- After each subagent completes a milestone, verify:
|
||||
- API/DB/job contracts are consistent,
|
||||
- any breaking changes are propagated.
|
||||
|
||||
## Guardrails
|
||||
- Prefer minimal moving parts in MVP.
|
||||
- Enforce external-archive policy: never delete/mutate `originals/`.
|
||||
- Presigned URLs must be compatible with tailnet HTTPS (no mixed content).
|
||||
|
||||
## Definition of Done (per milestone)
|
||||
- The milestone’s acceptance criteria are met.
|
||||
- Interfaces are documented and consumed correctly by other components.
|
||||
- Cross-cutting concerns checked: error handling, resource limits, and resiliency.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent: qa-review
|
||||
|
||||
**Model:** `github-copilot/claude-haiku-4.5`
|
||||
|
||||
## Mission
|
||||
Provide targeted QA, edge-case validation, and lightweight security/performance review to ensure the MVP is robust and doesn’t regress.
|
||||
|
||||
## Primary Responsibilities
|
||||
- Create a test plan focused on:
|
||||
- Missing EXIF for photos.
|
||||
- Conflicting/odd timestamps and timezone edge cases.
|
||||
- Corrupt files.
|
||||
- Unsupported video codecs/containers.
|
||||
- Large file uploads and slow networks.
|
||||
- Validate the “never break UI” principle:
|
||||
- Failed assets render as placeholders.
|
||||
- Timeline aggregation remains stable.
|
||||
- Networking checks:
|
||||
- Presigned URLs are HTTPS and use `minio.<tailnet-fqdn>`.
|
||||
- Video seeking works (Range support through exposure path).
|
||||
- Pi cluster constraints:
|
||||
- Ensure worker concurrency and limits don’t OOM.
|
||||
|
||||
## Inputs
|
||||
- `PLAN.md`
|
||||
- API contracts and any sample media set.
|
||||
- k8s deployment notes (affinity/resources)
|
||||
|
||||
## Outputs / Deliverables
|
||||
- A concise checklist of scenarios to validate.
|
||||
- A list of high-risk areas with mitigation suggestions.
|
||||
- Optional minimal automated tests if the repository has a test harness.
|
||||
|
||||
## Guardrails
|
||||
- Focus on MVP-critical risks; avoid broad refactors.
|
||||
- Flag any unsafe operations that might delete external originals.
|
||||
|
||||
## Definition of Done
|
||||
- A prioritized QA checklist exists.
|
||||
- Known edge cases have expected behavior documented.
|
||||
- No blocker issues remain unaddressed.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent: worker-media
|
||||
|
||||
**Model:** `github-copilot/claude-sonnet-4.5`
|
||||
|
||||
## Mission
|
||||
Implement the media processing worker: scanning, metadata extraction, thumbnail/poster generation, canonical copy logic, and safe CronJobs.
|
||||
|
||||
## Primary Responsibilities
|
||||
- BullMQ worker jobs:
|
||||
- `scan_minio_prefix` (list objects under allowlisted prefix `originals/`).
|
||||
- `process_asset` (extract metadata + generate derived assets).
|
||||
- `copy_to_canonical` (copy-only into date-based canonical layout).
|
||||
- Metadata extraction policy:
|
||||
- Photos: EXIF DateTimeOriginal-first.
|
||||
- Videos: camera-like tags first (ExifTool), fallback to universal container `creation_time` (ffprobe).
|
||||
- Derived assets:
|
||||
- Images: `thumbs/{assetId}/image_256.jpg` and `image_768.jpg` using `sharp`.
|
||||
- Videos: `thumbs/{assetId}/poster_256.jpg` using ffmpeg frame extraction.
|
||||
- Robustness:
|
||||
- Never crash the worker loop due to a single bad file.
|
||||
- Write `assets.status=failed` + `error_message` on failures.
|
||||
- Resource constraints:
|
||||
- Keep concurrency low (1–2) for Raspberry Pi.
|
||||
|
||||
## Inputs
|
||||
- `PLAN.md` (job semantics, key layout)
|
||||
- MinIO credentials + endpoints
|
||||
- DB access + Redis queue
|
||||
- Docker image must include required tools:
|
||||
- ExifTool
|
||||
- ffprobe/ffmpeg
|
||||
|
||||
## Outputs / Deliverables
|
||||
- Worker runnable in k8s.
|
||||
- Repeatable job behavior with idempotency considerations.
|
||||
- Derived outputs in MinIO, referenced from DB.
|
||||
|
||||
## Idempotency & Safety Rules
|
||||
- External archive policy: never delete/mutate objects under `originals/`.
|
||||
- `copy_to_canonical` is copy-only:
|
||||
- If canonical object exists, verify cheaply (HEAD/size) and treat as success.
|
||||
- Staging cleanup CronJob may safely delete old `staging/` objects.
|
||||
|
||||
## Definition of Done
|
||||
- Mixed media set (images + videos) processed end-to-end.
|
||||
- Poster + thumbs appear in MinIO for ready assets.
|
||||
- Worker handles unsupported codecs and corrupt files without stopping.
|
||||
- Copies to canonical work for uploads, and can be enabled later for scans.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
**/*.log
|
||||
.git
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,450 @@
|
||||
# Timeline Media Library — Implementation Plan
|
||||
|
||||
This document defines the conception and execution plan for a **web app** that ingests photos/videos, extracts metadata (capture date), and displays media on a **visual timeline tree** that supports **vertical and horizontal** orientations. The app runs in a **Kubernetes** cluster (Raspberry Pi heterogenous nodes) with **Longhorn** PVCs, **in-cluster MinIO** for storage, and **Tailscale Ingress** for private HTTPS access.
|
||||
|
||||
This plan is written to be executed by multiple subagents (parallelizable workstreams) each using a **specific LLM model**.
|
||||
|
||||
---
|
||||
|
||||
## Goals (MVP)
|
||||
|
||||
- Index media (photos + videos) and extract capture date metadata.
|
||||
- Render an interactive **timeline tree** (Year → Month → Day) with:
|
||||
- Orientation toggle: **vertical/horizontal**.
|
||||
- Zoom/pan (touch + mouse).
|
||||
- Expand/collapse nodes.
|
||||
- Provide a **mobile-friendly** UI with a bottom sheet details panel.
|
||||
- Support **videos** (play original if supported; show poster + fallback message otherwise).
|
||||
- Ingest sources:
|
||||
- **Admin upload** (cross-browser; no folder APIs required).
|
||||
- **Server-side scan** of MinIO prefix **`originals/`** (external archive).
|
||||
- Use **presigned URLs** for media delivery directly from MinIO.
|
||||
- Be resilient: broken/unsupported media should show placeholders and never break the app.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals (MVP)
|
||||
|
||||
- Authentication/authorization (rely on tailnet perimeter for now).
|
||||
- Location/map features.
|
||||
- User edits (fix dates, tagging, etc.).
|
||||
- Video transcoding (planned as future CronJob).
|
||||
- Deduplication.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions (Locked)
|
||||
|
||||
### App identity
|
||||
- App name: `porthole`
|
||||
- Set the app name via environment variable: `APP_NAME=porthole`.
|
||||
- Use `APP_NAME` everywhere (web + worker) via the shared config module so renaming is global.
|
||||
- If the UI needs to display the name in the browser, also provide `NEXT_PUBLIC_APP_NAME` (either set explicitly or derived at build time from `APP_NAME`).
|
||||
|
||||
### Networking
|
||||
- Tailnet clients access the app via **Tailscale Ingress HTTPS termination**.
|
||||
- MinIO is reachable **over tailnet** via a dedicated FQDN:
|
||||
- `https://minio.<tailnet-fqdn>` (S3 API)
|
||||
- `https://minio-console.<tailnet-fqdn>` (MinIO console)
|
||||
- App is reachable over tailnet:
|
||||
- `https://app.<tailnet-fqdn>`
|
||||
- Optional LAN ingress exists using `nip.io` and nginx ingress, but tailnet clients use Tailscale hostnames.
|
||||
|
||||
### Storage model
|
||||
- **MinIO is the source of truth**.
|
||||
- External archive objects under **`originals/`** are treated as **immutable**:
|
||||
- The app **indexes in place**.
|
||||
- The app **must never delete/mutate** external originals.
|
||||
- Canonical managed library is **copy-only**, pure date layout:
|
||||
- `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`
|
||||
- Uploads are processed then stored in canonical by default.
|
||||
|
||||
### Presigned URL strategy
|
||||
- Use **path-style presigned URLs** signed against:
|
||||
- `MINIO_PUBLIC_ENDPOINT_TS=https://minio.<tailnet-fqdn>`
|
||||
- Using HTTPS for MinIO on tailnet avoids mixed-content block when the app is served via HTTPS.
|
||||
|
||||
### Kubernetes constraints
|
||||
- Cluster nodes: **2× Raspberry Pi 5 (8GB)** + **1× Raspberry Pi 3 B+ (1GB)**.
|
||||
- Heavy pods must be pinned to Pi 5 nodes.
|
||||
- Multi-arch images required (arm64 + amd64), built on a laptop and pushed to an in-cluster **insecure HTTP registry**.
|
||||
|
||||
### Metadata extraction
|
||||
- **Photos**: camera-like EXIF first (`DateTimeOriginal`), then fallbacks.
|
||||
- **Videos**: camera-like tags first (ExifTool QuickTime/vendor tags), fallback to universal container `creation_time`.
|
||||
|
||||
### Derived media
|
||||
- Image thumbs: `image_256.jpg` and `image_768.jpg`.
|
||||
- Video posters: only `poster_256.jpg` initially (CPU-friendly).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
- **Web**: Next.js (UI + API)
|
||||
- **Worker**: Node worker using BullMQ
|
||||
- **Queue**: Redis
|
||||
- **DB**: Postgres
|
||||
- **Object store**: MinIO (in-cluster, single-node)
|
||||
|
||||
### Data flow
|
||||
1. Ingestion (upload or scan) creates/updates DB asset records.
|
||||
2. Worker extracts metadata and generates thumbs/posters.
|
||||
3. UI queries aggregated timeline nodes and displays a tree.
|
||||
4. UI fetches presigned URLs for rendering and playback.
|
||||
|
||||
---
|
||||
|
||||
## MinIO Object Layout (Single Bucket)
|
||||
|
||||
Example bucket: `media`.
|
||||
|
||||
- External archive (indexed in place):
|
||||
- `originals/**`
|
||||
- Upload staging (temporary):
|
||||
- `staging/{importId}/{uuid}.{ext}`
|
||||
- Canonical (copy only):
|
||||
- `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`
|
||||
- Derived thumbnails/posters:
|
||||
- `thumbs/{assetId}/image_256.jpg`
|
||||
- `thumbs/{assetId}/image_768.jpg`
|
||||
- `thumbs/{assetId}/poster_256.jpg`
|
||||
- Future derived video transcodes:
|
||||
- `derived/video/{assetId}/...`
|
||||
|
||||
---
|
||||
|
||||
## Database Model (MVP)
|
||||
|
||||
### Table: `assets`
|
||||
|
||||
- `id` (UUID)
|
||||
- `bucket` (text)
|
||||
- `media_type` (`image` | `video`)
|
||||
- `mime_type` (text)
|
||||
- Keys:
|
||||
- `source_key` (text, immutable)
|
||||
- `active_key` (text)
|
||||
- `canonical_key` (text, nullable)
|
||||
- Time:
|
||||
- `capture_ts_utc` (timestamptz)
|
||||
- `capture_offset_minutes` (int, nullable)
|
||||
- `date_confidence` (`camera` | `container` | `object_mtime` | `import_time`)
|
||||
- Media fields:
|
||||
- `width` (int, nullable)
|
||||
- `height` (int, nullable)
|
||||
- `rotation` (int, nullable)
|
||||
- `duration_seconds` (int, nullable)
|
||||
- Derived:
|
||||
- `thumb_small_key` (text, nullable)
|
||||
- `thumb_med_key` (text, nullable)
|
||||
- `poster_key` (text, nullable)
|
||||
- Processing:
|
||||
- `status` (`new` | `processing` | `ready` | `failed`)
|
||||
- `error_message` (text, nullable)
|
||||
- `raw_tags_json` (jsonb, optional but recommended for debugging)
|
||||
|
||||
Indexes:
|
||||
- `capture_ts_utc`, `status`, `media_type`
|
||||
|
||||
### Table: `imports`
|
||||
|
||||
- `id` (UUID)
|
||||
- `type` (`upload` | `minio_scan` | `normalize_copy`)
|
||||
- `status`
|
||||
- `created_at`
|
||||
- Optional counters for progress reporting.
|
||||
|
||||
---
|
||||
|
||||
## Worker Jobs (BullMQ)
|
||||
|
||||
### `scan_minio_prefix(importId, bucket, prefix)`
|
||||
- Guardrails: only allow prefixes from allowlist, starting with `originals/`.
|
||||
- Lists objects; upserts `assets` by `source_key`.
|
||||
- Enqueues `process_asset(assetId)`.
|
||||
|
||||
### `process_asset(assetId)`
|
||||
- Downloads object (stream or temp file).
|
||||
- Extracts metadata:
|
||||
- Photos: ExifTool EXIF chain.
|
||||
- Videos: ExifTool first; ffprobe fallback for `creation_time` and technical metadata.
|
||||
- Derived generation:
|
||||
- Images: `sharp` → `image_256.jpg`, `image_768.jpg`.
|
||||
- Videos: `ffmpeg` screenshot → `poster_256.jpg`.
|
||||
- Updates DB status.
|
||||
- Never throws errors that would crash the worker loop; failures are captured on the asset row.
|
||||
|
||||
### `copy_to_canonical(assetId)`
|
||||
- Computes canonical key: `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`.
|
||||
- Copy-only; never deletes `source_key` for external archive.
|
||||
- Updates `canonical_key` and flips `active_key`.
|
||||
|
||||
---
|
||||
|
||||
## API (MVP)
|
||||
|
||||
### Admin ingestion
|
||||
- `POST /api/imports` → create import batch
|
||||
- `POST /api/imports/:id/upload` → upload media to `staging/` and enqueue processing
|
||||
- `POST /api/imports/:id/scan-minio` → enqueue scan of allowlisted prefix
|
||||
- `GET /api/imports/:id/status` → progress
|
||||
|
||||
### Timeline and browsing
|
||||
- `GET /api/tree`
|
||||
- params: `start`, `end`, `granularity=year|month|day`, filters: `mediaType`
|
||||
- returns nodes with counts and sample thumbs
|
||||
- `GET /api/assets`
|
||||
- params: date-range + pagination + filters
|
||||
- `GET /api/assets/:id/url?variant=original|thumb_small|thumb_med|poster`
|
||||
- returns presigned URL pointing at `https://minio.<tailnet-fqdn>`
|
||||
|
||||
---
|
||||
|
||||
## Frontend UX/UI (MVP)
|
||||
|
||||
### Pages
|
||||
- `/` Timeline tree
|
||||
- `/admin` Admin tools (upload, scan, import status)
|
||||
|
||||
### Timeline tree
|
||||
- SVG tree rendering with:
|
||||
- Vertical/horizontal orientation toggle.
|
||||
- Zoom/pan (touch supported).
|
||||
- Expand/collapse nodes.
|
||||
- Detail panel:
|
||||
- Desktop: right-side panel.
|
||||
- Mobile: bottom sheet.
|
||||
- Virtualized thumbnail list.
|
||||
|
||||
### Viewer
|
||||
- Image viewer modal.
|
||||
- Video playback via HTML5 `<video>` on the presigned URL.
|
||||
- If a video can’t be played (codec/container): show poster + message.
|
||||
|
||||
### Resilience
|
||||
- Any media with `status=failed` renders as a placeholder tile and does not break aggregation or layout.
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Deployment Plan (Pi-aware)
|
||||
|
||||
### Scheduling
|
||||
- Label nodes:
|
||||
- Pi 5 nodes: `node-class=compute`
|
||||
- Pi 3 node: `node-class=tiny`
|
||||
- Pin pods to Pi 5:
|
||||
- `web`, `worker`, `minio`, `postgres`, `redis`
|
||||
|
||||
### Workloads
|
||||
- `StatefulSet/minio` (single-node) + Longhorn PVC
|
||||
- `StatefulSet/postgres` + Longhorn PVC
|
||||
- `Deployment/redis`
|
||||
- `Deployment/web`
|
||||
- `Deployment/worker` (BullMQ concurrency 1)
|
||||
- `CronJob/cleanup-staging` (optional; disabled by default)
|
||||
|
||||
### Exposure
|
||||
- Tailscale Ingress (HTTPS termination):
|
||||
- `app.<tailnet-fqdn>` → web service
|
||||
- `minio.<tailnet-fqdn>` → MinIO S3 (9000)
|
||||
- `minio-console.<tailnet-fqdn>` → MinIO console (9001)
|
||||
- Optional LAN nginx ingress + MetalLB for `nip.io` hostnames.
|
||||
|
||||
### Ingress notes
|
||||
- For uploads and media streaming, configure timeouts and body size to support “large but not gigantic” media.
|
||||
- Ensure Range requests work for video playback.
|
||||
|
||||
---
|
||||
|
||||
## Build & Release (Multi-arch)
|
||||
|
||||
### Package manager
|
||||
- Use **Bun** for installs and scripts (`bun install`, `bun run ...`).
|
||||
- Avoid `npm`/`pnpm` in CI and docs unless required for a specific tool.
|
||||
|
||||
### Container build
|
||||
- Build on laptop using Docker Buildx.
|
||||
- Push `linux/arm64` and `linux/amd64` images to local in-cluster registry over **insecure HTTP**.
|
||||
- Use Debian-slim Node base images for better ARM64 compatibility with `sharp` + ffmpeg.
|
||||
|
||||
---
|
||||
|
||||
## Execution Plan (Tasks + Subagents)
|
||||
|
||||
This plan is intended to be executed in parallel by multiple subagents. Each subagent uses a specific LLM model and follows its brief in `./.agents/`.
|
||||
|
||||
### Git commits (required during development)
|
||||
|
||||
- Treat each numbered task below as a **development phase**.
|
||||
- At the end of each phase (or a meaningful sub-slice), create a **small, scoped git commit** that cleanly compiles/tests for that phase.
|
||||
- Prefer **one commit per phase** when feasible; use multiple commits if a phase is large, but keep each commit independently reviewable.
|
||||
- Commit messages should reference the phase, e.g. `task-4: worker pipeline metadata + thumbs`.
|
||||
- Never commit secrets (credentials, tailnet hostnames, access keys); use `.env.example` / k8s `Secret` manifests or placeholders.
|
||||
|
||||
### Progress tracking (source of truth)
|
||||
|
||||
- `PLAN.md` is the **single source of truth** for project status.
|
||||
- Keep the table below updated in every PR/merge/phase-end commit that changes scope or completes work.
|
||||
- Exactly one task should be marked `in_progress` at a time.
|
||||
|
||||
| Task | Status | Notes |
|
||||
|---|---|---|
|
||||
| 1 — Repository scaffolding | completed | Bun workspace + shared config scaffold |
|
||||
| 2 — Database schema + migrations | completed | assets/imports schema + migration runner |
|
||||
| 3 — MinIO client + presigned URL strategy | completed | @tline/minio + presigned URL API route |
|
||||
| 4 — Worker pipeline (process images/videos) | completed | process_asset + scan_minio_prefix implemented |
|
||||
| 5 — Ingestion endpoints (upload + scan) | completed | imports create/upload/scan/status APIs |
|
||||
| 6 — Canonical copy logic (uploads default) | completed | copy_to_canonical worker job + enqueue on uploads |
|
||||
| 7 — Timeline aggregation API | completed | /api/tree implemented |
|
||||
| 8 — Timeline tree frontend | completed | basic SVG tree + orientation toggle |
|
||||
| 9 — Media panel + viewer | completed | day selection, asset list, preview + viewer |
|
||||
| 10 — k8s deployment (Pi-aware) | completed | Helm chart + Tailscale ingress |
|
||||
| 11 — QA + hardening | in_progress | Dockerfiles + MinIO Tailscale services added; pending deploy + end-to-end verification (Range, codec failures) |
|
||||
|
||||
- Entry point: `./.agents/README.md`
|
||||
- Agent briefs:
|
||||
- `./.agents/orchestrator.md`
|
||||
- `./.agents/backend-api.md`
|
||||
- `./.agents/worker-media.md`
|
||||
- `./.agents/frontend-ui.md`
|
||||
- `./.agents/k8s-infra.md`
|
||||
- `./.agents/qa-review.md`
|
||||
|
||||
### Subagents and assigned model
|
||||
|
||||
| Subagent | Responsibility | LLM Model |
|
||||
|---|---|---|
|
||||
| `orchestrator` | backlog coordination, interfaces, acceptance criteria | `github-copilot/gpt-5.2` |
|
||||
| `backend-api` | Next.js API routes, DB schema/migrations, presigned URL logic | `github-copilot/claude-sonnet-4.5` |
|
||||
| `worker-media` | BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters | `github-copilot/claude-sonnet-4.5` |
|
||||
| `frontend-ui` | timeline tree rendering, responsive layout, virtualization, styling | `github-copilot/gpt-5.2` |
|
||||
| `k8s-infra` | Helm/Kustomize, node affinity, MinIO/Postgres/Redis manifests, Tailscale ingress | `github-copilot/claude-sonnet-4.5` |
|
||||
| `qa-review` | test plan, edge cases, security review, performance checks | `github-copilot/claude-haiku-4.5` |
|
||||
|
||||
> Note: the model names above are intentionally explicit. If your environment exposes different model IDs, replace them consistently.
|
||||
|
||||
### Task breakdown (MVP)
|
||||
|
||||
#### Task 1 — Repository scaffolding
|
||||
- Define folder structure (apps/web, apps/worker, helm/).
|
||||
- Add shared `config` module (env validation).
|
||||
|
||||
Owner: `orchestrator` (brief: `./.agents/orchestrator.md`, model: `github-copilot/gpt-5.2`)
|
||||
|
||||
#### Task 2 — Database schema + migrations
|
||||
- Implement `assets`/`imports` schema.
|
||||
- Add indexes.
|
||||
|
||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 3 — MinIO client + presigned URL strategy
|
||||
- Implement internal client for cluster operations.
|
||||
- Implement public-signing client for tailnet endpoint.
|
||||
- Enforce path-style URLs.
|
||||
|
||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 4 — Worker pipeline (process images/videos)
|
||||
- ExifTool extraction (photos + camera-like video fields).
|
||||
- ffprobe technical metadata; fallback `creation_time`.
|
||||
- `sharp` thumbs for images.
|
||||
- ffmpeg poster extraction for videos.
|
||||
- Robust failure handling updates DB.
|
||||
|
||||
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 5 — Ingestion endpoints (upload + scan)
|
||||
- Admin upload endpoint: stream to `staging/`.
|
||||
- Scan endpoint: enqueue `scan_minio_prefix` only for allowlisted prefix `originals/`.
|
||||
- Import status endpoint.
|
||||
|
||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 6 — Canonical copy logic (uploads default)
|
||||
- For uploads, copy to canonical date key, flip `active_key`.
|
||||
- For scans, optional manual/cron copy.
|
||||
|
||||
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 7 — Timeline aggregation API
|
||||
- `GET /api/tree` for year/month/day rolling up counts.
|
||||
- Select sample thumbs per node.
|
||||
|
||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 8 — Timeline tree frontend
|
||||
- Interactive tree with orientation toggle.
|
||||
- Touch zoom/pan.
|
||||
- Expand/collapse.
|
||||
|
||||
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
|
||||
|
||||
#### Task 9 — Media panel + viewer
|
||||
- Virtualized thumbnail list.
|
||||
- Viewer modal for images.
|
||||
- Video playback with poster fallback.
|
||||
- Placeholder tiles for `failed` assets.
|
||||
|
||||
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
|
||||
|
||||
#### Task 10 — k8s deployment (Pi-aware)
|
||||
- Helm chart or Kustomize.
|
||||
- Node affinity to Pi 5 nodes.
|
||||
- Longhorn PVCs.
|
||||
- Tailscale ingress resources for app/minio/console.
|
||||
- CronJob cleanup staging.
|
||||
|
||||
Owner: `k8s-infra` (brief: `./.agents/k8s-infra.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||
|
||||
#### Task 11 — QA + hardening
|
||||
- Edge case tests: missing EXIF, odd timezones, unsupported video codecs.
|
||||
- Validate Range playback through ingress.
|
||||
- Verify no UI crash on failed assets.
|
||||
|
||||
Owner: `qa-review` (brief: `./.agents/qa-review.md`, model: `github-copilot/claude-haiku-4.5`)
|
||||
|
||||
---
|
||||
|
||||
## Future Features (Tracked)
|
||||
|
||||
### Security / Access
|
||||
- Authentication and authorization.
|
||||
- Lightweight admin protection (shared secret header) before full auth.
|
||||
|
||||
### Media
|
||||
- Video transcoding CronJob (H.264 MP4 and/or HLS) and “prefer derived” playback.
|
||||
- Multiple poster/thumb sizes.
|
||||
- Better codec support via transcode profiles.
|
||||
|
||||
### Organization
|
||||
- User-defined albums and tags.
|
||||
- Progressive enhancement for folder upload where supported.
|
||||
- Bucket separation (`media` vs `derived`) or lifecycle policies.
|
||||
|
||||
### Metadata
|
||||
- Location: GPS extraction + reverse geocoding + map UI.
|
||||
- Metadata edits/overrides (fix dates, correct capture time), audit log.
|
||||
|
||||
### Performance / Scale
|
||||
- Deduplication by hash.
|
||||
- Smarter clustering (“moments”) within a day.
|
||||
|
||||
### Networking
|
||||
- Routed LAN for tailnet clients (subnet router) and endpoint selection for presigned URLs.
|
||||
|
||||
### Delivery
|
||||
- Move multi-arch builds from laptop to CI.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Summary
|
||||
|
||||
- Can scan `originals/` and show a populated timeline.
|
||||
- Can upload media on mobile; it appears under canonical date layout.
|
||||
- Timeline tree renders and remains responsive on mobile.
|
||||
- Broken media shows placeholders; the app never crashes.
|
||||
- Video playback works when codec supported; poster shown otherwise.
|
||||
- Presigned URLs work over tailnet HTTPS against `minio.<tailnet-fqdn>`.
|
||||
- k8s scheduling pins heavy workloads to Pi 5 nodes.
|
||||
@@ -0,0 +1,221 @@
|
||||
# porthole
|
||||
|
||||
Porthole: timeline media library (Next.js web + worker), backed by Postgres/Redis/MinIO.
|
||||
|
||||
## How to try it
|
||||
|
||||
- Create a values file (example minimal):
|
||||
- set `secrets.postgres.password`
|
||||
- set `secrets.minio.accessKeyId` + `secrets.minio.secretAccessKey`
|
||||
- set `images.web.repository/tag` and `images.worker.repository/tag`
|
||||
- set `global.tailscale.tailnetFQDN` (recommended), or set `app.minio.publicEndpointTs` (must be `https://minio.<tailnet-fqdn>`)
|
||||
|
||||
- Render locally: `helm template porthole helm/tline -f your-values.yaml --namespace porthole`
|
||||
|
||||
- Install (to `porthole` namespace): `helm upgrade --install porthole helm/tline -f your-values.yaml --namespace porthole`
|
||||
|
||||
## ArgoCD
|
||||
|
||||
A ready-to-apply ArgoCD `Application` manifest is included at `argocd/tline-application.yaml` (it deploys the Helm release name `porthole`).
|
||||
|
||||
Reference example (deploys into the `porthole` namespace; the Helm chart itself does not hardcode a namespace):
|
||||
|
||||
```yaml
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: porthole
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@gitea-gitea-ssh.taildb3494.ts.net:will/porthole.git
|
||||
targetRevision: main
|
||||
path: helm/tline
|
||||
helm:
|
||||
releaseName: porthole
|
||||
valueFiles:
|
||||
- values.yaml
|
||||
# - values-porthole.yaml
|
||||
# Alternative to valueFiles: set a few values inline.
|
||||
# parameters:
|
||||
# - name: global.tailscale.tailnetFQDN
|
||||
# value: tailxyz.ts.net
|
||||
# - name: images.web.repository
|
||||
# value: registry.lan:5000/tline-web
|
||||
# - name: images.web.tag
|
||||
# value: dev
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: porthole
|
||||
|
||||
# Optional: if you use image pull secrets, you can set them via values files
|
||||
# or inline Helm parameters.
|
||||
# source:
|
||||
# helm:
|
||||
# parameters:
|
||||
# - name: imagePullSecrets[0]
|
||||
# value: my-registry-secret
|
||||
# - name: registrySecret.create
|
||||
# value: "true"
|
||||
# - name: registrySecret.server
|
||||
# value: registry.lan:5000
|
||||
# - name: registrySecret.username
|
||||
# value: your-user
|
||||
# - name: registrySecret.password
|
||||
# value: your-pass
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=false
|
||||
- ApplyOutOfSyncOnly=true
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- MinIO bucket creation: the app does not auto-create buckets. You can either create the bucket yourself, or enable the Helm hook job:
|
||||
- `jobs.ensureBucket.enabled=true`
|
||||
- Staging cleanup: disabled by default; enable with:
|
||||
- `cronjobs.cleanupStaging.enabled=true`
|
||||
|
||||
## Build + push images (multi-arch)
|
||||
|
||||
This repo is a Bun monorepo, but container builds use Docker Buildx.
|
||||
|
||||
- Assumptions:
|
||||
- You have an **in-cluster registry** reachable over **insecure HTTP** (example: `registry.lan:5000`).
|
||||
- Your Docker daemon is configured to allow that registry as an insecure registry.
|
||||
|
||||
- Create/use a buildx builder (one-time):
|
||||
- `docker buildx create --name tline --use`
|
||||
|
||||
- Build + push **web** (Next standalone):
|
||||
- `REGISTRY=registry.lan:5000 TAG=dev`
|
||||
- `docker buildx build --platform linux/amd64,linux/arm64 -f apps/web/Dockerfile -t "$REGISTRY/tline-web:$TAG" --push .`
|
||||
- Notes:
|
||||
- The Dockerfile uses `bun install --frozen-lockfile` and copies all workspace `package.json` files first to keep Bun from mutating `bun.lock`.
|
||||
- Runtime entrypoint comes from Next standalone output (the image runs `node app/apps/web/server.js`).
|
||||
|
||||
- Build + push **worker** (includes `ffmpeg` + `exiftool`):
|
||||
- `REGISTRY=registry.lan:5000 TAG=dev`
|
||||
- `docker buildx build --platform linux/amd64,linux/arm64 -f apps/worker/Dockerfile -t "$REGISTRY/tline-worker:$TAG" --push .`
|
||||
- Notes:
|
||||
- The Dockerfile uses `bun install --frozen-lockfile --production` and also copies all workspace `package.json` files first for stable `workspace:*` resolution.
|
||||
|
||||
- Then set Helm values:
|
||||
- `images.web.repository: registry.lan:5000/tline-web`
|
||||
- `images.web.tag: dev`
|
||||
- `images.worker.repository: registry.lan:5000/tline-worker`
|
||||
- `images.worker.tag: dev`
|
||||
|
||||
### Private registry auth (optional)
|
||||
|
||||
If your registry requires auth, you can either:
|
||||
- reference an existing Secret via `imagePullSecrets`, or
|
||||
- have the chart create a `kubernetes.io/dockerconfigjson` Secret via `registrySecret`.
|
||||
|
||||
Example values:
|
||||
|
||||
```yaml
|
||||
# Option A: reference an existing secret
|
||||
imagePullSecrets:
|
||||
- my-registry-secret
|
||||
|
||||
# Option B: create a secret from values (stores creds in values)
|
||||
registrySecret:
|
||||
create: true
|
||||
server: "registry.lan:5000"
|
||||
username: "your-user"
|
||||
password: "your-pass"
|
||||
email: "you@example.com"
|
||||
```
|
||||
|
||||
## MinIO exposure (Tailscale)
|
||||
|
||||
MinIO S3 URLs must be signed against `https://minio.<tailnet-fqdn>`.
|
||||
|
||||
You can expose MinIO over tailnet either via:
|
||||
- **Tailscale Ingress** (default), or
|
||||
- **Tailscale LoadBalancer Service** (often more reliable for streaming/Range)
|
||||
|
||||
Example values (LoadBalancer for S3 + console):
|
||||
|
||||
```yaml
|
||||
global:
|
||||
tailscale:
|
||||
tailnetFQDN: "tailxyz.ts.net"
|
||||
|
||||
minio:
|
||||
tailscaleServiceS3:
|
||||
enabled: true
|
||||
hostnameLabel: minio
|
||||
tailscaleServiceConsole:
|
||||
enabled: true
|
||||
hostnameLabel: minio-console
|
||||
|
||||
# Optional: if you prefer explicit override instead of deriving from tailnetFQDN
|
||||
# app:
|
||||
# minio:
|
||||
# publicEndpointTs: "https://minio.tailxyz.ts.net"
|
||||
```
|
||||
|
||||
## Example values (Pi cluster)
|
||||
|
||||
This chart assumes you label nodes like:
|
||||
- Pi 5 nodes: `node-class=compute`
|
||||
- Pi 3 node: `node-class=tiny`
|
||||
|
||||
The default scheduling in `helm/tline/values.yaml` pins heavy pods to `node-class=compute`.
|
||||
|
||||
Example `values.yaml` you can start from:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
postgres:
|
||||
password: "change-me"
|
||||
minio:
|
||||
accessKeyId: "minioadmin"
|
||||
secretAccessKey: "minioadmin"
|
||||
|
||||
images:
|
||||
web:
|
||||
repository: registry.lan:5000/tline-web
|
||||
tag: dev
|
||||
worker:
|
||||
repository: registry.lan:5000/tline-worker
|
||||
tag: dev
|
||||
|
||||
global:
|
||||
tailscale:
|
||||
tailnetFQDN: "tailxyz.ts.net"
|
||||
|
||||
# Optional, but common for Pi clusters (Longhorn default shown as example)
|
||||
# global:
|
||||
# storageClass: longhorn
|
||||
|
||||
minio:
|
||||
# Prefer LB Services for streaming/Range reliability
|
||||
tailscaleServiceS3:
|
||||
enabled: true
|
||||
hostnameLabel: minio
|
||||
tailscaleServiceConsole:
|
||||
enabled: true
|
||||
hostnameLabel: minio-console
|
||||
|
||||
jobs:
|
||||
ensureBucket:
|
||||
enabled: true
|
||||
|
||||
# Optional staging cleanup (never touches originals/**)
|
||||
# cronjobs:
|
||||
# cleanupStaging:
|
||||
# enabled: true
|
||||
# olderThanDays: 7
|
||||
```
|
||||
|
||||
## Quick checks
|
||||
|
||||
- Range support through ingress (expect `206`):
|
||||
- `curl -sS -D- -H 'Range: bytes=0-1023' "$(curl -sS https://app.<tailnet-fqdn>/api/assets/<assetId>/url?variant=original | jq -r .url)" -o /dev/null`
|
||||
@@ -0,0 +1,38 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM oven/bun:1.3.3 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests (copy all workspace package.json files so Bun
|
||||
# can resolve workspace:* deps without mutating the lockfile).
|
||||
COPY package.json bun.lock tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
COPY apps/worker/package.json ./apps/worker/package.json
|
||||
COPY packages/config/package.json ./packages/config/package.json
|
||||
COPY packages/db/package.json ./packages/db/package.json
|
||||
COPY packages/minio/package.json ./packages/minio/package.json
|
||||
COPY packages/queue/package.json ./packages/queue/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/web ./apps/web
|
||||
COPY packages ./packages
|
||||
|
||||
# Build Next standalone output
|
||||
RUN bun run --cwd apps/web build
|
||||
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Next standalone output contains its own node_modules tree.
|
||||
# With outputFileTracingRoot set, standalone output nests under the basename
|
||||
# of that tracing root ("app" inside this Docker build).
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./app/apps/web/.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "app/apps/web/server.js"]
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<main style={{ padding: 16 }}>
|
||||
<h1 style={{ marginTop: 0 }}>Admin</h1>
|
||||
<p>Upload + scan tools will live here.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { presignGetObjectUrl } from "@tline/minio";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid()
|
||||
});
|
||||
|
||||
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
|
||||
if (!variantParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: variantParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const variant = variantParsed.data;
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
bucket: string;
|
||||
active_key: string;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
mime_type: string;
|
||||
}[]
|
||||
>`
|
||||
select bucket, active_key, thumb_small_key, thumb_med_key, poster_key, mime_type
|
||||
from assets
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const key =
|
||||
variant === "original"
|
||||
? asset.active_key
|
||||
: variant === "thumb_small"
|
||||
? asset.thumb_small_key
|
||||
: variant === "thumb_med"
|
||||
? asset.thumb_med_key
|
||||
: asset.poster_key;
|
||||
|
||||
if (!key) {
|
||||
return Response.json(
|
||||
{ error: "variant_not_available", variant },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hint the browser; especially helpful for Range playback.
|
||||
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
|
||||
|
||||
const responseContentDisposition =
|
||||
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
|
||||
|
||||
const signed = await presignGetObjectUrl({
|
||||
bucket: asset.bucket,
|
||||
key,
|
||||
responseContentType,
|
||||
responseContentDisposition,
|
||||
});
|
||||
|
||||
return Response.json(signed, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
start: z.string().datetime().optional(),
|
||||
end: z.string().datetime().optional(),
|
||||
mediaType: z.enum(["image", "video"]).optional(),
|
||||
status: z.enum(["new", "processing", "ready", "failed"]).optional(),
|
||||
limit: z.coerce.number().int().positive().max(200).default(60),
|
||||
cursor: z.string().uuid().optional(),
|
||||
cursorTs: z.string().datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const parsed = querySchema.safeParse({
|
||||
start: url.searchParams.get("start") ?? undefined,
|
||||
end: url.searchParams.get("end") ?? undefined,
|
||||
mediaType: url.searchParams.get("mediaType") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
cursorTs: url.searchParams.get("cursorTs") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const query = parsed.data;
|
||||
|
||||
const start = query.start ? new Date(query.start) : null;
|
||||
const end = query.end ? new Date(query.end) : null;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Cursor pagination: (capture_ts_utc, id) > (cursorTs, cursor)
|
||||
const cursorTs = query.cursorTs ? new Date(query.cursorTs) : null;
|
||||
const cursorId = query.cursor ?? null;
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
active_key: string;
|
||||
capture_ts_utc: string | null;
|
||||
date_confidence: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
rotation: number | null;
|
||||
duration_seconds: number | null;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
}[]
|
||||
>`
|
||||
select
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
mime_type,
|
||||
active_key,
|
||||
capture_ts_utc,
|
||||
date_confidence,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
duration_seconds,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key,
|
||||
status,
|
||||
error_message
|
||||
from assets
|
||||
where true
|
||||
and capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (${query.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status)
|
||||
and (
|
||||
${cursorId}::uuid is null
|
||||
or ${cursorTs}::timestamptz is null
|
||||
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
|
||||
)
|
||||
order by capture_ts_utc asc nulls last, id asc
|
||||
limit ${query.limit}
|
||||
`;
|
||||
|
||||
const nextCursor = rows.length > 0 ? rows[rows.length - 1] : null;
|
||||
|
||||
return Response.json({
|
||||
start: start ? start.toISOString() : null,
|
||||
end: end ? end.toISOString() : null,
|
||||
items: rows,
|
||||
next:
|
||||
nextCursor && nextCursor.capture_ts_utc
|
||||
? { cursor: nextCursor.id, cursorTs: nextCursor.capture_ts_utc }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket } from "@tline/minio";
|
||||
import { enqueueScanMinioPrefix } from "@tline/queue";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
bucket: z.string().min(1).optional(),
|
||||
prefix: z.string().min(1).default("originals/"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const bucket = body.bucket ?? getMinioBucket();
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
}[]
|
||||
>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const imp = rows[0];
|
||||
if (!imp) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await enqueueScanMinioPrefix({
|
||||
importId: imp.id,
|
||||
bucket,
|
||||
prefix: body.prefix,
|
||||
});
|
||||
|
||||
await db`
|
||||
update imports
|
||||
set status = 'queued'
|
||||
where id = ${imp.id}
|
||||
`;
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const db = getDb();
|
||||
|
||||
const [imp] = await db<
|
||||
{
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
total_count: number | null;
|
||||
processed_count: number | null;
|
||||
failed_count: number | null;
|
||||
}[]
|
||||
>`
|
||||
select id, type, status, created_at, total_count, processed_count, failed_count
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const counts = await db<
|
||||
{
|
||||
total: number;
|
||||
ready: number;
|
||||
failed: number;
|
||||
processing: number;
|
||||
new_count: number;
|
||||
}[]
|
||||
>`
|
||||
select
|
||||
count(*)::int as total,
|
||||
count(*) filter (where status = 'ready')::int as ready,
|
||||
count(*) filter (where status = 'failed')::int as failed,
|
||||
count(*) filter (where status = 'processing')::int as processing,
|
||||
count(*) filter (where status = 'new')::int as new_count
|
||||
from assets
|
||||
where source_key like ${`staging/${imp.id}/%`}
|
||||
`;
|
||||
|
||||
return Response.json({ ...imp, asset_counts: counts[0] ?? null });
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
|
||||
import { enqueueProcessAsset } from "@tline/queue";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const contentTypeMediaMap: Array<{
|
||||
match: (ct: string) => boolean;
|
||||
mediaType: "image" | "video";
|
||||
}> = [
|
||||
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
|
||||
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
|
||||
];
|
||||
|
||||
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
|
||||
const found = contentTypeMediaMap.find((m) => m.match(ct));
|
||||
return found?.mediaType ?? null;
|
||||
}
|
||||
|
||||
function inferExtFromContentType(ct: string): string {
|
||||
const parts = ct.split("/");
|
||||
const ext = parts[1] ?? "bin";
|
||||
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
|
||||
const mediaType = inferMediaTypeFromContentType(contentType);
|
||||
if (!mediaType) {
|
||||
return Response.json({ error: "unsupported_content_type", contentType }, { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = getMinioBucket();
|
||||
const ext = inferExtFromContentType(contentType);
|
||||
const objectId = randomUUID();
|
||||
const key = `staging/${params.id}/${objectId}.${ext}`;
|
||||
|
||||
const db = getDb();
|
||||
const [imp] = await db<{ id: string }[]>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return Response.json({ error: "import_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!request.body) {
|
||||
return Response.json({ error: "missing_body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const s3 = getMinioInternalClient();
|
||||
const bodyStream = Readable.fromWeb(request.body as unknown as NodeReadableStream);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: bodyStream,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update set active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "asset_insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, assetId: asset.id, bucket, key });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
type: z.enum(["upload", "minio_scan"]).default("upload"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
type: "upload" | "minio_scan";
|
||||
status: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into imports (type, status)
|
||||
values (${body.type}, 'new')
|
||||
returning id, type, status, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return Response.json({ error: "insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json(created);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
start: z.string().datetime().optional(),
|
||||
end: z.string().datetime().optional(),
|
||||
granularity: z.enum(["year", "month", "day"]).default("day"),
|
||||
mediaType: z.enum(["image", "video"]).optional(),
|
||||
includeFailed: z.enum(["0", "1"]).default("0").transform((v) => v === "1"),
|
||||
limit: z.coerce.number().int().positive().max(500).default(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type Granularity = z.infer<typeof querySchema>["granularity"];
|
||||
|
||||
function sqlGroupExpr(granularity: Granularity, alias: string) {
|
||||
const col = `${alias}.capture_ts_utc`;
|
||||
if (granularity === "year") return `date_trunc('year', ${col})`;
|
||||
if (granularity === "month") return `date_trunc('month', ${col})`;
|
||||
return `date_trunc('day', ${col})`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const parsed = querySchema.safeParse({
|
||||
start: url.searchParams.get("start") ?? undefined,
|
||||
end: url.searchParams.get("end") ?? undefined,
|
||||
granularity: url.searchParams.get("granularity") ?? undefined,
|
||||
mediaType: url.searchParams.get("mediaType") ?? undefined,
|
||||
includeFailed: url.searchParams.get("includeFailed") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const query = parsed.data;
|
||||
|
||||
const start = query.start ? new Date(query.start) : null;
|
||||
const end = query.end ? new Date(query.end) : null;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Note: capture_ts_utc can be null (unprocessed). Those rows are excluded.
|
||||
const groupExprFiltered = sqlGroupExpr(query.granularity, "filtered");
|
||||
const groupExprF = sqlGroupExpr(query.granularity, "f");
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
bucket: string;
|
||||
group_ts: string;
|
||||
count_total: number;
|
||||
count_ready: number;
|
||||
sample_asset_id: string | null;
|
||||
sample_thumb_small_key: string | null;
|
||||
sample_thumb_med_key: string | null;
|
||||
sample_poster_key: string | null;
|
||||
sample_active_key: string | null;
|
||||
sample_status: string | null;
|
||||
sample_media_type: "image" | "video" | null;
|
||||
}[]
|
||||
>`
|
||||
with filtered as (
|
||||
select
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
status,
|
||||
capture_ts_utc,
|
||||
active_key,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key
|
||||
from assets
|
||||
where capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (
|
||||
${query.includeFailed}::boolean = true
|
||||
or status <> 'failed'
|
||||
)
|
||||
),
|
||||
grouped as (
|
||||
select
|
||||
bucket,
|
||||
${db.unsafe(groupExprFiltered)} as group_ts,
|
||||
count(*)::int as count_total,
|
||||
count(*) filter (where status = 'ready')::int as count_ready
|
||||
from filtered
|
||||
group by bucket, ${db.unsafe(groupExprFiltered)}
|
||||
order by group_ts desc
|
||||
limit ${query.limit}
|
||||
)
|
||||
select
|
||||
g.bucket,
|
||||
to_char(g.group_ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as group_ts,
|
||||
g.count_total,
|
||||
g.count_ready,
|
||||
s.id as sample_asset_id,
|
||||
s.thumb_small_key as sample_thumb_small_key,
|
||||
s.thumb_med_key as sample_thumb_med_key,
|
||||
s.poster_key as sample_poster_key,
|
||||
s.active_key as sample_active_key,
|
||||
s.status as sample_status,
|
||||
s.media_type as sample_media_type
|
||||
from grouped g
|
||||
left join lateral (
|
||||
select *
|
||||
from filtered f
|
||||
where f.bucket = g.bucket
|
||||
and ${db.unsafe(groupExprF)} = g.group_ts
|
||||
and f.status = 'ready'
|
||||
order by f.capture_ts_utc asc
|
||||
limit 1
|
||||
) s on true
|
||||
order by g.group_ts desc
|
||||
`;
|
||||
|
||||
return Response.json({
|
||||
granularity: query.granularity,
|
||||
start: start ? start.toISOString() : null,
|
||||
end: end ? end.toISOString() : null,
|
||||
mediaType: query.mediaType ?? null,
|
||||
includeFailed: query.includeFailed,
|
||||
nodes: rows,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Asset = {
|
||||
id: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
capture_ts_utc: string | null;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
type AssetsResponse = {
|
||||
items: Asset[];
|
||||
};
|
||||
|
||||
type SignedUrlResponse = {
|
||||
url: string;
|
||||
expiresSeconds: number;
|
||||
};
|
||||
|
||||
type PreviewUrlState = Record<string, string | undefined>;
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0));
|
||||
}
|
||||
|
||||
function endOfDayUtc(iso: string) {
|
||||
const start = startOfDayUtc(iso);
|
||||
return new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
const [assets, setAssets] = useState<Asset[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previews, setPreviews] = useState<PreviewUrlState>({});
|
||||
|
||||
const [viewer, setViewer] = useState<{
|
||||
asset: Asset;
|
||||
url: string;
|
||||
variant: "original" | "thumb_med" | "poster";
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
const start = startOfDayUtc(props.selectedDayIso);
|
||||
const end = endOfDayUtc(props.selectedDayIso);
|
||||
return { start, end };
|
||||
}, [props.selectedDayIso]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!range) {
|
||||
setAssets(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setAssets(null);
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
limit: "120",
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as AssetsResponse;
|
||||
|
||||
if (!cancelled) {
|
||||
setAssets(json.items);
|
||||
setPreviews({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
|
||||
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
|
||||
const json = (await res.json()) as SignedUrlResponse;
|
||||
return json.url;
|
||||
}
|
||||
|
||||
async function openViewer(asset: Asset) {
|
||||
if (asset.status === "failed") {
|
||||
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
|
||||
setViewer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<strong>Media</strong>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>
|
||||
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!assets && props.selectedDayIso && !error ? (
|
||||
<div style={{ color: "#666" }}>Loading assets…</div>
|
||||
) : null}
|
||||
|
||||
{assets ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => void openViewer(a)}
|
||||
onPointerEnter={() => {
|
||||
if (previews[a.id] !== undefined) return;
|
||||
|
||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
||||
|
||||
void promise.then((url) => {
|
||||
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: 10,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
background: "#f2f2f2",
|
||||
border: "1px solid #eee",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previews[a.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
) : (
|
||||
<span>{a.media_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<span>
|
||||
{a.media_type} · {a.status}
|
||||
</span>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
{a.status === "failed" && a.error_message ? (
|
||||
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewer || viewerError ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={viewer.url}
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
poster={videoFallback?.posterUrl ?? undefined}
|
||||
onError={() => {
|
||||
setViewerError("video_playback_failed");
|
||||
if (videoFallback !== null) return;
|
||||
setVideoFallback({ posterUrl: null });
|
||||
void loadSignedUrl(viewer.asset.id, "poster")
|
||||
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
||||
.catch(() => setVideoFallback({ posterUrl: null }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewerError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{viewerError}
|
||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Granularity = "year" | "month" | "day";
|
||||
|
||||
type TreeNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
ts: string; // ISO string from API
|
||||
countTotal: number;
|
||||
countReady: number;
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type ApiTreeRow = {
|
||||
bucket: string;
|
||||
group_ts: string;
|
||||
count_total: number;
|
||||
count_ready: number;
|
||||
};
|
||||
|
||||
type ApiTreeResponse = {
|
||||
granularity: Granularity;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
nodes: ApiTreeRow[];
|
||||
};
|
||||
|
||||
type Orientation = "vertical" | "horizontal";
|
||||
|
||||
type ExpandedState = Record<string, boolean>;
|
||||
|
||||
function monthKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function yearKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
||||
const years = new Map<string, TreeNode>();
|
||||
const months = new Map<string, TreeNode>();
|
||||
|
||||
for (const row of dayRows) {
|
||||
const date = new Date(row.group_ts);
|
||||
const year = yearKey(date);
|
||||
const month = monthKey(date);
|
||||
const day = dayKey(date);
|
||||
|
||||
const yMapKey = `${row.bucket}:${year}`;
|
||||
const mMapKey = `${row.bucket}:${month}`;
|
||||
|
||||
let yNode = years.get(yMapKey);
|
||||
if (!yNode) {
|
||||
yNode = {
|
||||
id: `y:${year}:${row.bucket}`,
|
||||
label: year,
|
||||
ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
years.set(yMapKey, yNode);
|
||||
}
|
||||
|
||||
let mNode = months.get(mMapKey);
|
||||
if (!mNode) {
|
||||
const [yy, mm] = month.split("-");
|
||||
mNode = {
|
||||
id: `m:${month}:${row.bucket}`,
|
||||
label: `${yy}-${mm}`,
|
||||
ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
months.set(mMapKey, mNode);
|
||||
yNode.children!.push(mNode);
|
||||
}
|
||||
|
||||
const dNode: TreeNode = {
|
||||
id: `d:${day}:${row.bucket}`,
|
||||
label: day,
|
||||
ts: row.group_ts,
|
||||
countTotal: row.count_total,
|
||||
countReady: row.count_ready,
|
||||
};
|
||||
|
||||
mNode.children!.push(dNode);
|
||||
|
||||
// rollups
|
||||
mNode.countTotal += row.count_total;
|
||||
mNode.countReady += row.count_ready;
|
||||
yNode.countTotal += row.count_total;
|
||||
yNode.countReady += row.count_ready;
|
||||
}
|
||||
|
||||
// Sort ascending within each parent
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const y of yearNodes) {
|
||||
y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const m of y.children) {
|
||||
m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
}
|
||||
|
||||
return yearNodes;
|
||||
}
|
||||
|
||||
function gatherVisible(
|
||||
roots: TreeNode[],
|
||||
expanded: ExpandedState,
|
||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
||||
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = [];
|
||||
|
||||
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
||||
for (const n of nodes) {
|
||||
out.push({ node: n, depth, parentId });
|
||||
const isExpanded = expanded[n.id] ?? depth < 1; // default expand years
|
||||
if (n.children?.length && isExpanded) {
|
||||
walk(n.children, depth + 1, n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(roots, 0, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
|
||||
const [orientation, setOrientation] = useState<Orientation>("vertical");
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// simple pan/zoom via viewBox
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 });
|
||||
const dragState = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startViewBox: typeof viewBox;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as ApiTreeResponse;
|
||||
if (!cancelled) setRows(json.nodes);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const nodeGap = 56;
|
||||
const depthGap = 180;
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const { node, depth } = visible[i];
|
||||
const primary = i * nodeGap;
|
||||
const secondary = depth * depthGap;
|
||||
|
||||
const x = orientation === "vertical" ? secondary : primary;
|
||||
const y = orientation === "vertical" ? primary : secondary;
|
||||
|
||||
positions.set(node.id, { x, y });
|
||||
}
|
||||
|
||||
// compute bounds
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
for (const p of positions.values()) {
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
|
||||
const padding = 120;
|
||||
const w = maxX + padding * 2;
|
||||
const h = maxY + padding * 2;
|
||||
|
||||
return { positions, w, h, padding };
|
||||
}, [visible, orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
// reset viewBox when layout changes
|
||||
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
|
||||
}, [layout.w, layout.h]);
|
||||
|
||||
function toggleNode(id: string) {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) }));
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
dragState.current = {
|
||||
active: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startViewBox: viewBox,
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
||||
if (!dragState.current?.active) return;
|
||||
const dx = e.clientX - dragState.current.startX;
|
||||
const dy = e.clientY - dragState.current.startY;
|
||||
|
||||
// Convert pixels to viewBox units roughly; this is intentionally simple.
|
||||
const scaleX = viewBox.w / 900;
|
||||
const scaleY = viewBox.h / 600;
|
||||
|
||||
setViewBox({
|
||||
...viewBox,
|
||||
x: dragState.current.startViewBox.x - dx * scaleX,
|
||||
y: dragState.current.startViewBox.y - dy * scaleY,
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<SVGSVGElement>) {
|
||||
dragState.current = null;
|
||||
try {
|
||||
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 0.9 : 1.1;
|
||||
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
const px = (mouseX - rect.left) / rect.width;
|
||||
const py = (mouseY - rect.top) / rect.height;
|
||||
|
||||
const newW = Math.max(300, Math.min(5000, viewBox.w * factor));
|
||||
const newH = Math.max(300, Math.min(5000, viewBox.h * factor));
|
||||
|
||||
const newX = viewBox.x + (viewBox.w - newW) * px;
|
||||
const newY = viewBox.y + (viewBox.h - newH) * py;
|
||||
|
||||
setViewBox({ x: newX, y: newY, w: newW, h: newH });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<strong>Timeline</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
||||
>
|
||||
Orientation: {orientation}
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
|
||||
Reset view
|
||||
</button>
|
||||
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!rows && !error ? <div style={{ color: "#666" }}>Loading tree…</div> : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
height: 600,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
style={{ touchAction: "none", background: "#fafafa" }}
|
||||
>
|
||||
<g transform={`translate(${layout.padding} ${layout.padding})`}>
|
||||
{/* edges */}
|
||||
{visible.map(({ node, parentId }) => {
|
||||
if (!parentId) return null;
|
||||
const p = layout.positions.get(parentId);
|
||||
const c = layout.positions.get(node.id);
|
||||
if (!p || !c) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${parentId}->${node.id}`}
|
||||
x1={p.x}
|
||||
y1={p.y}
|
||||
x2={c.x}
|
||||
y2={c.y}
|
||||
stroke="#bbb"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* nodes */}
|
||||
{visible.map(({ node }) => {
|
||||
const pos = layout.positions.get(node.id);
|
||||
if (!pos) return null;
|
||||
|
||||
const hasChildren = Boolean(node.children?.length);
|
||||
const isExpanded = expanded[node.id] ?? node.id.startsWith("y:");
|
||||
|
||||
const isDay = node.id.startsWith("d:");
|
||||
const clickCursor = hasChildren || isDay ? "pointer" : "default";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${pos.x} ${pos.y})`}
|
||||
style={{ cursor: clickCursor }}
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleNode(node.id);
|
||||
if (isDay) props.onSelectDay?.(node.ts);
|
||||
}}
|
||||
>
|
||||
<circle r={12} fill={hasChildren ? "#1f6feb" : "#666"} />
|
||||
<text x={20} y={5} fontSize={14} fill="#111">
|
||||
{node.label} ({node.countReady}/{node.countTotal})
|
||||
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
Drag to pan, mouse wheel to zoom. Click years/months to expand.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getAppName } from "@tline/config";
|
||||
|
||||
export const metadata = {
|
||||
title: getAppName()
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { getAppName } from "@tline/config";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MediaPanel } from "./components/MediaPanel";
|
||||
import { TimelineTree } from "./components/TimelineTree";
|
||||
|
||||
export default function HomePage() {
|
||||
const [selectedDayIso, setSelectedDayIso] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<main style={{ padding: 16, display: "grid", gap: 16 }}>
|
||||
<header>
|
||||
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/admin">Admin</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api/healthz">API health</a>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<TimelineTree onSelectDay={setSelectedDayIso} />
|
||||
<MediaPanel selectedDayIso={selectedDayIso} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: path.join(__dirname, "../../.."),
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@tline/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"next": "15.5.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"types": [
|
||||
"bun-types"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM oven/bun:1.3.3 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests (copy all workspace package.json files so Bun
|
||||
# can resolve workspace:* deps without mutating the lockfile).
|
||||
COPY package.json bun.lock tsconfig.base.json ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
COPY apps/worker/package.json ./apps/worker/package.json
|
||||
COPY packages/config/package.json ./packages/config/package.json
|
||||
COPY packages/db/package.json ./packages/db/package.json
|
||||
COPY packages/minio/package.json ./packages/minio/package.json
|
||||
COPY packages/queue/package.json ./packages/queue/package.json
|
||||
|
||||
RUN bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
FROM oven/bun:1.3.3 AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Media tooling for worker pipeline
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/package.json ./package.json
|
||||
COPY --from=deps /app/bun.lock ./bun.lock
|
||||
|
||||
COPY apps/worker ./apps/worker
|
||||
COPY packages ./packages
|
||||
|
||||
CMD ["bun", "--cwd", "apps/worker", "run", "start"]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tline/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"bullmq": "^5.61.0",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getAppName } from "@tline/config";
|
||||
import { Worker, type Job } from "bullmq";
|
||||
|
||||
import { closeQueue, getQueueEnv, getQueueName, getRedis } from "@tline/queue";
|
||||
import { closeDb } from "@tline/db";
|
||||
|
||||
import {
|
||||
handleCopyToCanonical,
|
||||
handleProcessAsset,
|
||||
handleScanMinioPrefix
|
||||
} from "./jobs";
|
||||
|
||||
console.log(`[${getAppName()}] worker boot`);
|
||||
|
||||
const env = getQueueEnv();
|
||||
const queueName = getQueueName();
|
||||
|
||||
const connection = getRedis();
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
} catch (err) {
|
||||
console.error(`[${getAppName()}] redis connect failed`, { err, redisUrl: env.REDIS_URL });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const worker = new Worker(
|
||||
queueName,
|
||||
async (job: Job) => {
|
||||
if (job.name === "scan_minio_prefix") return handleScanMinioPrefix(job.data);
|
||||
if (job.name === "process_asset") return handleProcessAsset(job.data);
|
||||
if (job.name === "copy_to_canonical") return handleCopyToCanonical(job.data);
|
||||
|
||||
throw new Error(`Unknown job: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1
|
||||
}
|
||||
);
|
||||
|
||||
worker.on("failed", (job: Job | undefined, err: Error) => {
|
||||
console.error(`[${getAppName()}] job failed`, { jobId: job?.id, name: job?.name, err });
|
||||
});
|
||||
|
||||
worker.on("completed", (job: Job) => {
|
||||
console.log(`[${getAppName()}] job completed`, { jobId: job.id, name: job.name });
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[${getAppName()}] shutting down`, { signal });
|
||||
await Promise.allSettled([worker.close(), closeDb()]);
|
||||
await Promise.allSettled([closeQueue()]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
@@ -0,0 +1,616 @@
|
||||
import { spawn } from "child_process";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { createWriteStream, createReadStream } from "fs";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioInternalClient } from "@tline/minio";
|
||||
import {
|
||||
copyToCanonicalPayloadSchema,
|
||||
enqueueCopyToCanonical,
|
||||
enqueueProcessAsset,
|
||||
processAssetPayloadSchema,
|
||||
scanMinioPrefixPayloadSchema,
|
||||
} from "@tline/queue";
|
||||
|
||||
const allowedScanPrefixes = ["originals/"] as const;
|
||||
|
||||
function assertAllowedScanPrefix(prefix: string) {
|
||||
if (allowedScanPrefixes.some((allowed) => prefix.startsWith(allowed))) return;
|
||||
throw new Error(`scan prefix not allowed: ${prefix}`);
|
||||
}
|
||||
|
||||
function getExtensionLower(key: string) {
|
||||
const dot = key.lastIndexOf(".");
|
||||
if (dot === -1) return "";
|
||||
return key.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferMedia(
|
||||
key: string,
|
||||
): { mediaType: "image" | "video"; mimeType: string } | null {
|
||||
const ext = getExtensionLower(key);
|
||||
|
||||
if (["jpg", "jpeg"].includes(ext))
|
||||
return { mediaType: "image", mimeType: "image/jpeg" };
|
||||
if (ext === "png") return { mediaType: "image", mimeType: "image/png" };
|
||||
if (ext === "gif") return { mediaType: "image", mimeType: "image/gif" };
|
||||
if (ext === "webp") return { mediaType: "image", mimeType: "image/webp" };
|
||||
if (ext === "heic") return { mediaType: "image", mimeType: "image/heic" };
|
||||
if (ext === "heif") return { mediaType: "image", mimeType: "image/heif" };
|
||||
|
||||
if (ext === "mov") return { mediaType: "video", mimeType: "video/quicktime" };
|
||||
if (ext === "mp4") return { mediaType: "video", mimeType: "video/mp4" };
|
||||
if (ext === "m4v") return { mediaType: "video", mimeType: "video/x-m4v" };
|
||||
if (ext === "mkv")
|
||||
return { mediaType: "video", mimeType: "video/x-matroska" };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllObjectKeys(input: { bucket: string; prefix: string }) {
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
const keys: string[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const res = await s3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: input.bucket,
|
||||
Prefix: input.prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const obj of res.Contents ?? []) {
|
||||
if (!obj.Key) continue;
|
||||
keys.push(obj.Key);
|
||||
}
|
||||
|
||||
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export async function handleScanMinioPrefix(raw: unknown) {
|
||||
const payload = scanMinioPrefixPayloadSchema.parse(raw);
|
||||
|
||||
assertAllowedScanPrefix(payload.prefix);
|
||||
|
||||
const keys = await listAllObjectKeys({
|
||||
bucket: payload.bucket,
|
||||
prefix: payload.prefix,
|
||||
});
|
||||
|
||||
const db = getDb();
|
||||
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
let enqueued = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.endsWith("/")) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inferred = inferMedia(key);
|
||||
if (!inferred) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${payload.bucket}, ${inferred.mediaType}, ${inferred.mimeType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update
|
||||
set media_type = excluded.media_type,
|
||||
mime_type = excluded.mime_type,
|
||||
active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
processed++;
|
||||
|
||||
const [asset] = rows;
|
||||
if (!asset) continue;
|
||||
|
||||
if (asset.status === "new" || asset.status === "failed") {
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
enqueued++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
importId: payload.importId,
|
||||
bucket: payload.bucket,
|
||||
scannedPrefix: payload.prefix,
|
||||
found: keys.length,
|
||||
processed,
|
||||
skipped,
|
||||
enqueued,
|
||||
};
|
||||
}
|
||||
|
||||
function streamToFile(stream: Readable, filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const writeStream = createWriteStream(filePath);
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on("finish", resolve);
|
||||
writeStream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(cmd, args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`${cmd} failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadObject(input: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
filePath: string;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const s3 = getMinioInternalClient();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.key,
|
||||
Body: createReadStream(input.filePath),
|
||||
ContentType: input.contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
|
||||
const s3 = getMinioInternalClient();
|
||||
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
|
||||
return res.LastModified ?? null;
|
||||
}
|
||||
|
||||
function parseExifDate(dateStr: string | undefined): Date | null {
|
||||
if (!dateStr) return null;
|
||||
const s = dateStr.trim();
|
||||
|
||||
// ExifTool commonly emits: "YYYY:MM:DD HH:MM:SS", sometimes with fractional seconds and/or tz.
|
||||
const m = s.match(
|
||||
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}))?$/,
|
||||
);
|
||||
|
||||
if (m) {
|
||||
const [, y, mo, d, hh, mm, ss, frac, tz] = m;
|
||||
// If tz missing, prefer deterministic UTC over server-local interpretation.
|
||||
const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}${frac ?? ""}${tz ?? "Z"}`;
|
||||
const date = new Date(iso);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
const date = new Date(s);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function isPlausibleCaptureTs(date: Date) {
|
||||
const ts = date.getTime();
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
const year = date.getUTCFullYear();
|
||||
// Guard against bogus container/default dates; allow up to 24h in future.
|
||||
return year >= 1971 && ts <= Date.now() + 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function inferExtFromKey(key: string): string {
|
||||
const ext = getExtensionLower(key);
|
||||
return ext || "bin";
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function utcDateParts(date: Date) {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate();
|
||||
return { y, m, d };
|
||||
}
|
||||
|
||||
export async function handleProcessAsset(raw: unknown) {
|
||||
const payload = processAssetPayloadSchema.parse(raw);
|
||||
const db = getDb();
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set status = 'processing', error_message = null
|
||||
where id = ${payload.assetId}
|
||||
and status in ('new', 'failed')
|
||||
`;
|
||||
|
||||
try {
|
||||
const [asset] = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
active_key: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
created_at: Date;
|
||||
}[]
|
||||
>`
|
||||
select id, bucket, active_key, media_type, mime_type, created_at
|
||||
from assets
|
||||
where id = ${payload.assetId}
|
||||
`;
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`Asset not found: ${payload.assetId}`);
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "tline-process-"));
|
||||
|
||||
try {
|
||||
const containerExt = asset.mime_type.split("/")[1] ?? "bin";
|
||||
const inputPath = join(tempDir, `input.${containerExt}`);
|
||||
const getRes = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: asset.active_key,
|
||||
}),
|
||||
);
|
||||
if (!getRes.Body) throw new Error("Empty response body from S3");
|
||||
await streamToFile(getRes.Body as Readable, inputPath);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
capture_ts_utc: null,
|
||||
date_confidence: null,
|
||||
width: null,
|
||||
height: null,
|
||||
rotation: null,
|
||||
duration_seconds: null,
|
||||
thumb_small_key: null,
|
||||
thumb_med_key: null,
|
||||
poster_key: null,
|
||||
raw_tags_json: null
|
||||
};
|
||||
let rawTags: Record<string, unknown> = {};
|
||||
let captureTs: Date | null = null;
|
||||
let dateConfidence:
|
||||
| "camera"
|
||||
| "container"
|
||||
| "object_mtime"
|
||||
| "import_time"
|
||||
| null = null;
|
||||
|
||||
async function tryReadExifTags(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const exifOutput = await runCommand("exiftool", ["-j", inputPath]);
|
||||
const exifData = JSON.parse(exifOutput);
|
||||
if (Array.isArray(exifData) && exifData.length > 0) {
|
||||
const first = exifData[0];
|
||||
if (first && typeof first === "object") {
|
||||
return first as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { exiftool_error: message };
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSetCaptureDateFromTags(tags: Record<string, unknown>) {
|
||||
if (captureTs) return;
|
||||
// ExifTool uses different fields across image/video vendors.
|
||||
const dateFields = [
|
||||
"DateTimeOriginal",
|
||||
"CreateDate",
|
||||
"ModifyDate",
|
||||
"MediaCreateDate",
|
||||
"TrackCreateDate",
|
||||
"CreationDate",
|
||||
"GPSDateTime",
|
||||
] as const;
|
||||
for (const field of dateFields) {
|
||||
const val = tags[field] as string | undefined;
|
||||
if (!val) continue;
|
||||
const parsed = parseExifDate(val);
|
||||
if (parsed && isPlausibleCaptureTs(parsed)) {
|
||||
captureTs = parsed;
|
||||
dateConfidence = "camera";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyObjectMtimeFallback() {
|
||||
if (captureTs) return;
|
||||
try {
|
||||
const mtime = await getObjectLastModified({
|
||||
bucket: asset.bucket,
|
||||
key: asset.active_key,
|
||||
});
|
||||
if (!mtime) return;
|
||||
if (!isPlausibleCaptureTs(mtime)) return;
|
||||
captureTs = mtime;
|
||||
dateConfidence = "object_mtime";
|
||||
rawTags = { ...rawTags, object_last_modified: mtime.toISOString() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
rawTags = { ...rawTags, object_last_modified_error: message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (asset.media_type === "image") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
await applyObjectMtimeFallback();
|
||||
|
||||
|
||||
if (rawTags.ImageWidth !== undefined) updates.width = Number(rawTags.ImageWidth);
|
||||
if (rawTags.ImageHeight !== undefined) updates.height = Number(rawTags.ImageHeight);
|
||||
if (rawTags.Rotation !== undefined) updates.rotation = Number(rawTags.Rotation);
|
||||
|
||||
const imgMeta = await sharp(inputPath).metadata();
|
||||
if (updates.width === null && imgMeta.width) updates.width = imgMeta.width;
|
||||
if (updates.height === null && imgMeta.height) updates.height = imgMeta.height;
|
||||
|
||||
const thumb256Path = join(tempDir, "thumb_256.jpg");
|
||||
const thumb768Path = join(tempDir, "thumb_768.jpg");
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb256Path);
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(768, 768, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb768Path);
|
||||
|
||||
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`;
|
||||
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb256Key,
|
||||
filePath: thumb256Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb768Key,
|
||||
filePath: thumb768Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.thumb_small_key = thumb256Key;
|
||||
updates.thumb_med_key = thumb768Key;
|
||||
} else if (asset.media_type === "video") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
|
||||
const ffprobeOutput = await runCommand("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,duration",
|
||||
"-show_entries",
|
||||
"format_tags=creation_time",
|
||||
"-of",
|
||||
"json",
|
||||
inputPath
|
||||
]);
|
||||
const ffprobeData = JSON.parse(ffprobeOutput);
|
||||
|
||||
if (!captureTs && ffprobeData.format?.tags?.creation_time) {
|
||||
const ts = new Date(ffprobeData.format.tags.creation_time);
|
||||
if (!isNaN(ts.getTime()) && isPlausibleCaptureTs(ts)) {
|
||||
captureTs = ts;
|
||||
dateConfidence = "container";
|
||||
}
|
||||
}
|
||||
|
||||
await applyObjectMtimeFallback();
|
||||
|
||||
if (ffprobeData.streams?.[0]) {
|
||||
const stream = ffprobeData.streams[0];
|
||||
if (stream.width) updates.width = Number(stream.width);
|
||||
if (stream.height) updates.height = Number(stream.height);
|
||||
if (stream.duration)
|
||||
updates.duration_seconds = Math.round(Number(stream.duration));
|
||||
}
|
||||
|
||||
rawTags = { ...rawTags, ffprobe: ffprobeData };
|
||||
|
||||
const posterPath = join(tempDir, "poster_256.jpg");
|
||||
await runCommand("ffmpeg", [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
"scale=256:256:force_original_aspect_ratio=decrease",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
posterPath
|
||||
]);
|
||||
const posterKey = `thumbs/${asset.id}/poster_256.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: posterKey,
|
||||
filePath: posterPath,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.poster_key = posterKey;
|
||||
}
|
||||
|
||||
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
|
||||
throw new Error("poster generation did not produce output");
|
||||
}
|
||||
|
||||
if (
|
||||
asset.media_type === "image" &&
|
||||
(typeof updates.thumb_small_key !== "string" || typeof updates.thumb_med_key !== "string")
|
||||
) {
|
||||
throw new Error("thumb generation did not produce output");
|
||||
}
|
||||
|
||||
if (!captureTs) {
|
||||
captureTs = new Date(asset.created_at);
|
||||
dateConfidence = "import_time";
|
||||
rawTags = {
|
||||
...rawTags,
|
||||
capture_date_fallback: "import_time",
|
||||
};
|
||||
}
|
||||
|
||||
updates.capture_ts_utc = captureTs;
|
||||
updates.date_confidence = dateConfidence;
|
||||
updates.raw_tags_json = rawTags;
|
||||
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set ${db(
|
||||
updates,
|
||||
"capture_ts_utc",
|
||||
"date_confidence",
|
||||
"width",
|
||||
"height",
|
||||
"rotation",
|
||||
"duration_seconds",
|
||||
"thumb_small_key",
|
||||
"thumb_med_key",
|
||||
"poster_key",
|
||||
"raw_tags_json"
|
||||
)}, status = 'ready', error_message = null
|
||||
where id = ${asset.id}
|
||||
`;
|
||||
|
||||
// Only uploads (staging/*) are copied into canonical by default.
|
||||
if (asset.active_key.startsWith("staging/")) {
|
||||
await enqueueCopyToCanonical({ assetId: asset.id });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown_error";
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set status = 'failed', error_message = ${message}
|
||||
where id = ${payload.assetId}
|
||||
`;
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCopyToCanonical(raw: unknown) {
|
||||
const payload = copyToCanonicalPayloadSchema.parse(raw);
|
||||
|
||||
const db = getDb();
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
const [asset] = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
source_key: string;
|
||||
active_key: string;
|
||||
canonical_key: string | null;
|
||||
capture_ts_utc: Date | null;
|
||||
}[]
|
||||
>`
|
||||
select id, bucket, source_key, active_key, canonical_key, capture_ts_utc
|
||||
from assets
|
||||
where id = ${payload.assetId}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
|
||||
|
||||
// Canonical layout is date-based; if we don't have a date yet, do nothing.
|
||||
// This job can be retried later after metadata extraction improves.
|
||||
if (!asset.capture_ts_utc) {
|
||||
return { ok: true, assetId: asset.id, skipped: "missing_capture_ts" };
|
||||
}
|
||||
|
||||
// Never copy external archive originals by default.
|
||||
if (asset.source_key.startsWith("originals/")) {
|
||||
return { ok: true, assetId: asset.id, skipped: "external_archive" };
|
||||
}
|
||||
|
||||
const ext = inferExtFromKey(asset.source_key);
|
||||
const { y, m, d } = utcDateParts(new Date(asset.capture_ts_utc));
|
||||
const canonicalKey = `canonical/originals/${y}/${pad2(m)}/${pad2(d)}/${asset.id}.${ext}`;
|
||||
|
||||
// Idempotency: if already canonicalized, don't redo work.
|
||||
if (asset.canonical_key === canonicalKey && asset.active_key === canonicalKey) {
|
||||
return { ok: true, assetId: asset.id, canonicalKey, already: true };
|
||||
}
|
||||
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: canonicalKey,
|
||||
CopySource: `${asset.bucket}/${asset.active_key}`,
|
||||
MetadataDirective: "COPY",
|
||||
}),
|
||||
);
|
||||
|
||||
await db`
|
||||
update assets
|
||||
set canonical_key = ${canonicalKey}, active_key = ${canonicalKey}
|
||||
where id = ${asset.id}
|
||||
`;
|
||||
|
||||
return { ok: true, assetId: asset.id, canonicalKey };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../packages/*/src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: porthole
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@gitea-ssh.gitea.svc:will/porthole.git
|
||||
targetRevision: main
|
||||
path: helm/tline
|
||||
helm:
|
||||
releaseName: porthole
|
||||
valueFiles:
|
||||
- values.yaml
|
||||
# - values-porthole.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: porthole
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=false
|
||||
- ApplyOutOfSyncOnly=true
|
||||
@@ -0,0 +1,693 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tline",
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"bun-types": "^1.3.5",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@tline/web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"next": "15.5.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
},
|
||||
},
|
||||
"apps/worker": {
|
||||
"name": "@tline/worker",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"@tline/config": "workspace:*",
|
||||
"@tline/db": "workspace:*",
|
||||
"@tline/minio": "workspace:*",
|
||||
"@tline/queue": "workspace:*",
|
||||
"bullmq": "^5.61.0",
|
||||
"sharp": "^0.33.5",
|
||||
},
|
||||
},
|
||||
"packages/config": {
|
||||
"name": "@tline/config",
|
||||
"version": "0.0.0",
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@tline/db",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
},
|
||||
"packages/minio": {
|
||||
"name": "@tline/minio",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.899.0",
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
},
|
||||
"packages/queue": {
|
||||
"name": "@tline/queue",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"bullmq": "^5.61.0",
|
||||
"ioredis": "^5.8.0",
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.958.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-node": "3.958.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-expect-continue": "3.957.0", "@aws-sdk/middleware-flexible-checksums": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/middleware-ssec": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/eventstream-serde-browser": "^4.2.7", "@smithy/eventstream-serde-config-resolver": "^4.3.7", "@smithy/eventstream-serde-node": "^4.2.7", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-blob-browser": "^4.2.8", "@smithy/hash-node": "^4.2.7", "@smithy/hash-stream-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/md5-js": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg=="],
|
||||
|
||||
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/xml-builder": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw=="],
|
||||
|
||||
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-qSwSfI+qBU9HDsd6/4fM9faCxYJx2yDuHtj+NVOQ6XYDWQzFab/hUdwuKZ77Pi6goLF1pBZhJ2azaC2w7LbnTA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-stream": "^4.5.8", "tslib": "^2.6.2" } }, "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-login": "3.958.0", "@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.958.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-ini": "3.958.0", "@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.958.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.958.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/token-providers": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iczcn/QRIBSpvsdAS/rbzmoBpleX1JBjXvCynMbDceVLBIcVrwT1hXECrhtIC2cjh4HaLo9ClAbiOiWuqt+6MA=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-AlbK3OeVNwZZil0wlClgeI/ISlOt/SPUxBsIns876IFaVu/Pj3DgImnYhpcJuFRek4r4XM51xzIaGQXM6GDHGg=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.957.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/crc64-nvme": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA=="],
|
||||
|
||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-y8/W7TOQpmDJg/fPYlqAhwA4+I15LrS7TwgUEoxogtkD8gfur9wFMRLT8LCyc9o4NMEcAnK50hSb4+wB0qv6tQ=="],
|
||||
|
||||
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ=="],
|
||||
|
||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-qwkmrK0lizdjNt5qxl4tHYfASh8DFpHXM1iDVo+qHe+zuslfMqQEGRkzxS8tJq/I+8F0c6v3IKOveKJAfIvfqQ=="],
|
||||
|
||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw=="],
|
||||
|
||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A=="],
|
||||
|
||||
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.958.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-format-url": "3.957.0", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-bFKsofead/fl3lyhdES+aNo+MZ+qv1ixSPSsF8O1oj6/KgGE0t1UH9AHw2vPq6iSQMTeEuyV0F5pC+Ns40kBgA=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.957.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg=="],
|
||||
|
||||
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.957.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g=="],
|
||||
|
||||
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-endpoints": "^3.2.7", "tslib": "^2.6.2" } }, "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw=="],
|
||||
|
||||
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-Yyo/tlc0iGFGTPPkuxub1uRAv6XrnVnvSNjslZh5jIYA8GZoeEFPgJa3Qdu0GUS/YwoK8GOLnnaL9h/eH5LDJQ=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.957.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.957.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.2", "", {}, "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
||||
|
||||
"@next/env": ["@next/env@15.5.3", "", {}, "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
|
||||
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.20.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.8", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.7", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA=="],
|
||||
|
||||
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.7", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g=="],
|
||||
|
||||
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.7", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.7", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg=="],
|
||||
|
||||
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.8", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw=="],
|
||||
|
||||
"@smithy/hash-node": ["@smithy/hash-node@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw=="],
|
||||
|
||||
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ=="],
|
||||
|
||||
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
|
||||
|
||||
"@smithy/md5-js": ["@smithy/md5-js@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.7", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.1", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-serde": "^4.2.8", "@smithy/node-config-provider": "^4.3.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-middleware": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/service-error-classification": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w=="],
|
||||
|
||||
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw=="],
|
||||
|
||||
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.7", "", { "dependencies": { "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/property-provider@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA=="],
|
||||
|
||||
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA=="],
|
||||
|
||||
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="],
|
||||
|
||||
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w=="],
|
||||
|
||||
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0" } }, "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA=="],
|
||||
|
||||
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.2", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.7", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.10.2", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-stack": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-stream": "^4.5.8", "tslib": "^2.6.2" } }, "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="],
|
||||
|
||||
"@smithy/url-parser": ["@smithy/url-parser@4.2.7", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg=="],
|
||||
|
||||
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
|
||||
|
||||
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
|
||||
|
||||
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.16", "", { "dependencies": { "@smithy/property-provider": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.19", "", { "dependencies": { "@smithy/config-resolver": "^4.4.5", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.7", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg=="],
|
||||
|
||||
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
|
||||
|
||||
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w=="],
|
||||
|
||||
"@smithy/util-retry": ["@smithy/util-retry@4.2.7", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.8", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
|
||||
|
||||
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw=="],
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tline/config": ["@tline/config@workspace:packages/config"],
|
||||
|
||||
"@tline/db": ["@tline/db@workspace:packages/db"],
|
||||
|
||||
"@tline/minio": ["@tline/minio@workspace:packages/minio"],
|
||||
|
||||
"@tline/queue": ["@tline/queue@workspace:packages/queue"],
|
||||
|
||||
"@tline/web": ["@tline/web@workspace:apps/web"],
|
||||
|
||||
"@tline/worker": ["@tline/worker@workspace:apps/worker"],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"bullmq": ["bullmq@5.66.2", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.8.2", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.3", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-0PrkpIakIntkBcPLltPIRWdLC1FTLUa/VhJkmEfobb5YUQjoUwJdmmf7HX+o/vMonS5048JpP+abf9lVRUFEjA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["next@15.5.3", "", { "dependencies": { "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-x64": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-musl": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw=="],
|
||||
|
||||
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: tline
|
||||
description: Timeline media library (porthole)
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.0.0"
|
||||
@@ -0,0 +1,151 @@
|
||||
{{- define "tline.name" -}}
|
||||
{{- default .Chart.Name .Values.global.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.fullname" -}}
|
||||
{{- if .Values.global.fullnameOverride -}}
|
||||
{{- .Values.global.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name (include "tline.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.labels" -}}
|
||||
app.kubernetes.io/name: {{ include "tline.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "tline.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.componentName" -}}
|
||||
{{- printf "%s-%s" (include "tline.fullname" .) .component | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.storageClass" -}}
|
||||
{{- $sc := .storageClass | default "" -}}
|
||||
{{- if $sc -}}
|
||||
{{- $sc -}}
|
||||
{{- else if .Values.global.storageClass -}}
|
||||
{{- .Values.global.storageClass -}}
|
||||
{{- else -}}
|
||||
{{- "" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.affinity" -}}
|
||||
{{- $class := .schedulingClass | default "compute" -}}
|
||||
{{- $sched := index .Values.scheduling $class -}}
|
||||
{{- if $sched.affinity -}}
|
||||
{{- toYaml $sched.affinity -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.tolerations" -}}
|
||||
{{- $class := .schedulingClass | default "compute" -}}
|
||||
{{- $sched := index .Values.scheduling $class -}}
|
||||
{{- if $sched.tolerations -}}
|
||||
{{- toYaml $sched.tolerations -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.secretName" -}}
|
||||
{{- if .Values.secrets.existingSecret -}}
|
||||
{{- .Values.secrets.existingSecret -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-secrets" (include "tline.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.databaseUrl" -}}
|
||||
{{- if .Values.app.databaseUrl -}}
|
||||
{{- .Values.app.databaseUrl -}}
|
||||
{{- else if not .Values.postgres.enabled -}}
|
||||
{{- fail "app.databaseUrl is required when postgres.enabled=false" -}}
|
||||
{{- else if .Values.secrets.existingSecret -}}
|
||||
{{- fail "app.databaseUrl is required when secrets.existingSecret is set (password not available in values)" -}}
|
||||
{{- else -}}
|
||||
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") -}}
|
||||
{{- printf "postgres://%s:%s@%s:%d/%s" .Values.secrets.postgres.user .Values.secrets.postgres.password $svc (.Values.postgres.service.port | int) .Values.secrets.postgres.database -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.redisUrl" -}}
|
||||
{{- if .Values.app.redisUrl -}}
|
||||
{{- .Values.app.redisUrl -}}
|
||||
{{- else if not .Values.redis.enabled -}}
|
||||
{{- fail "app.redisUrl is required when redis.enabled=false" -}}
|
||||
{{- else -}}
|
||||
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") -}}
|
||||
{{- printf "redis://%s:%d" $svc (.Values.redis.service.port | int) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.minioInternalEndpoint" -}}
|
||||
{{- if .Values.app.minio.internalEndpoint -}}
|
||||
{{- .Values.app.minio.internalEndpoint -}}
|
||||
{{- else if not .Values.minio.enabled -}}
|
||||
{{- fail "app.minio.internalEndpoint is required when minio.enabled=false" -}}
|
||||
{{- else -}}
|
||||
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
|
||||
{{- printf "http://%s:%d" $svc (.Values.minio.service.s3Port | int) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.minioPublicEndpointTs" -}}
|
||||
{{- if .Values.app.minio.publicEndpointTs -}}
|
||||
{{- .Values.app.minio.publicEndpointTs -}}
|
||||
{{- else if and .Values.global.tailscale.enabled .Values.global.tailscale.tailnetFQDN -}}
|
||||
{{- $label := .Values.minio.ingressS3.hostnameLabel -}}
|
||||
{{- if .Values.minio.tailscaleServiceS3.enabled -}}
|
||||
{{- $label = .Values.minio.tailscaleServiceS3.hostnameLabel -}}
|
||||
{{- end -}}
|
||||
{{- printf "https://%s.%s" $label .Values.global.tailscale.tailnetFQDN -}}
|
||||
{{- else -}}
|
||||
{{- fail "app.minio.publicEndpointTs is required (or set global.tailscale.tailnetFQDN to derive it)" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.image" -}}
|
||||
{{- $repo := .repository | default "" -}}
|
||||
{{- $tag := .tag | default "" -}}
|
||||
{{- if or (not $repo) (not $tag) -}}
|
||||
{{- fail "image repository and tag must be set" -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s:%s" $repo $tag -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.migrateImage" -}}
|
||||
{{- $repo := .Values.jobs.migrate.image.repository | default .Values.images.worker.repository -}}
|
||||
{{- $tag := .Values.jobs.migrate.image.tag | default .Values.images.worker.tag -}}
|
||||
{{- $policy := .Values.jobs.migrate.image.pullPolicy | default .Values.images.worker.pullPolicy -}}
|
||||
{{- if or (not $repo) (not $tag) -}}
|
||||
{{- fail "migrate image repository/tag must be set (jobs.migrate.image or images.worker)" -}}
|
||||
{{- end -}}
|
||||
{{- dict "image" (printf "%s:%s" $repo $tag) "pullPolicy" $policy | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.registrySecretName" -}}
|
||||
{{- if .Values.registrySecret.name -}}
|
||||
{{- .Values.registrySecret.name -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-registry" (include "tline.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.imagePullSecrets" -}}
|
||||
{{- $secrets := .Values.imagePullSecrets | default (list) -}}
|
||||
{{- if .Values.registrySecret.create -}}
|
||||
{{- $secrets = append $secrets (include "tline.registrySecretName" .) -}}
|
||||
{{- end -}}
|
||||
{{- if gt (len $secrets) 0 -}}
|
||||
imagePullSecrets:
|
||||
{{- range $name := $secrets }}
|
||||
- name: {{ $name | quote }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
data:
|
||||
APP_NAME: {{ .Values.app.name | quote }}
|
||||
NEXT_PUBLIC_APP_NAME: {{ .Values.app.name | quote }}
|
||||
QUEUE_NAME: {{ .Values.app.queueName | quote }}
|
||||
DATABASE_URL: {{ include "tline.databaseUrl" . | quote }}
|
||||
REDIS_URL: {{ include "tline.redisUrl" . | quote }}
|
||||
MINIO_INTERNAL_ENDPOINT: {{ include "tline.minioInternalEndpoint" . | quote }}
|
||||
MINIO_PUBLIC_ENDPOINT_TS: {{ include "tline.minioPublicEndpointTs" . | quote }}
|
||||
MINIO_REGION: {{ .Values.app.minio.region | quote }}
|
||||
MINIO_BUCKET: {{ .Values.app.minio.bucket | quote }}
|
||||
MINIO_PRESIGN_EXPIRES_SECONDS: {{ .Values.app.minio.presignExpiresSeconds | quote }}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{- if and .Values.cronjobs.cleanupStaging.enabled .Values.minio.enabled -}}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "cleanup-staging") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: cleanup-staging
|
||||
spec:
|
||||
schedule: {{ .Values.cronjobs.cleanupStaging.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 1
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 12 }}
|
||||
app.kubernetes.io/component: cleanup-staging
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{ include "tline.imagePullSecrets" . | indent 10 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 12 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 12 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: cleanup
|
||||
image: {{ printf "%s:%s" .Values.cronjobs.cleanupStaging.image.repository .Values.cronjobs.cleanupStaging.image.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.cronjobs.cleanupStaging.image.pullPolicy }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "Configuring mc alias..."
|
||||
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
|
||||
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
|
||||
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
|
||||
|
||||
echo "Removing staged objects older than {{ .Values.cronjobs.cleanupStaging.olderThanDays }}d..."
|
||||
mc rm --recursive --force --older-than "{{ .Values.cronjobs.cleanupStaging.olderThanDays }}d" "local/{{ .Values.app.minio.bucket }}/staging" || true
|
||||
env:
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
{{ toYaml .Values.cronjobs.cleanupStaging.resources | indent 16 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,120 @@
|
||||
{{- if and .Values.global.tailscale.enabled .Values.web.enabled .Values.web.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
annotations:
|
||||
{{- if .Values.global.tailscale.proxyGroup.enabled }}
|
||||
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.web.ingress.funnel }}
|
||||
tailscale.com/funnel: "true"
|
||||
{{- end }}
|
||||
{{- if .Values.web.ingress.tags }}
|
||||
tailscale.com/tags: {{ join "," .Values.web.ingress.tags | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.web.ingress.extraAnnotations }}
|
||||
{{ toYaml . | indent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.web.ingress.hostnameLabel | quote }}
|
||||
rules:
|
||||
- host: {{ .Values.web.ingress.hostnameLabel | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.web.ingress.path }}
|
||||
pathType: {{ .Values.web.ingress.pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
|
||||
port:
|
||||
number: {{ .Values.web.service.port }}
|
||||
{{ end }}
|
||||
|
||||
{{- $disableMinioS3Ingress := and .Values.minio.tailscaleServiceS3.enabled (.Values.minio.ingressS3DisabledWhenTailscaleService | default true) -}}
|
||||
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.ingressS3.enabled (not $disableMinioS3Ingress) -}}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
{{- if .Values.global.tailscale.proxyGroup.enabled }}
|
||||
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.minio.ingressS3.funnel }}
|
||||
tailscale.com/funnel: "true"
|
||||
{{- end }}
|
||||
{{- if .Values.minio.ingressS3.tags }}
|
||||
tailscale.com/tags: {{ join "," .Values.minio.ingressS3.tags | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.minio.ingressS3.extraAnnotations }}
|
||||
{{ toYaml . | indent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.minio.ingressS3.hostnameLabel | quote }}
|
||||
rules:
|
||||
- host: {{ .Values.minio.ingressS3.hostnameLabel | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.minio.ingressS3.path }}
|
||||
pathType: {{ .Values.minio.ingressS3.pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
port:
|
||||
number: {{ .Values.minio.service.s3Port }}
|
||||
{{ end }}
|
||||
|
||||
{{- $disableMinioConsoleIngress := and .Values.minio.tailscaleServiceConsole.enabled (.Values.minio.ingressConsoleDisabledWhenTailscaleService | default true) -}}
|
||||
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.ingressConsole.enabled (not $disableMinioConsoleIngress) -}}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-console") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
{{- if .Values.global.tailscale.proxyGroup.enabled }}
|
||||
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.minio.ingressConsole.funnel }}
|
||||
tailscale.com/funnel: "true"
|
||||
{{- end }}
|
||||
{{- if .Values.minio.ingressConsole.tags }}
|
||||
tailscale.com/tags: {{ join "," .Values.minio.ingressConsole.tags | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.minio.ingressConsole.extraAnnotations }}
|
||||
{{ toYaml . | indent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.minio.ingressConsole.hostnameLabel | quote }}
|
||||
rules:
|
||||
- host: {{ .Values.minio.ingressConsole.hostnameLabel | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.minio.ingressConsole.path }}
|
||||
pathType: {{ .Values.minio.ingressConsole.pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
port:
|
||||
number: {{ .Values.minio.service.consolePort }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,64 @@
|
||||
{{- if and .Values.jobs.ensureBucket.enabled .Values.minio.enabled -}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "ensure-bucket") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: ensure-bucket
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-20"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: ensure-bucket
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: ensure-bucket
|
||||
image: {{ printf "%s:%s" .Values.jobs.ensureBucket.image.repository .Values.jobs.ensureBucket.image.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.jobs.ensureBucket.image.pullPolicy }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "Configuring mc alias..."
|
||||
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
|
||||
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
|
||||
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
|
||||
|
||||
echo "Ensuring bucket exists: {{ .Values.app.minio.bucket }}"
|
||||
mc mb --ignore-existing "local/{{ .Values.app.minio.bucket }}"
|
||||
|
||||
# Never mutate or delete originals/**. This job only ensures the bucket exists.
|
||||
env:
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
{{ toYaml .Values.jobs.ensureBucket.resources | indent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{- if .Values.jobs.migrate.enabled -}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "migrate") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-10"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: 3
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
{{- $img := fromYaml (include "tline.migrateImage" .) }}
|
||||
image: {{ $img.image | quote }}
|
||||
imagePullPolicy: {{ $img.pullPolicy }}
|
||||
command:
|
||||
- bun
|
||||
- run
|
||||
- packages/db/src/migrate.ts
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
{{- end }}
|
||||
@@ -0,0 +1,107 @@
|
||||
{{- if .Values.minio.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: s3
|
||||
port: {{ .Values.minio.service.s3Port }}
|
||||
targetPort: s3
|
||||
- name: console
|
||||
port: {{ .Values.minio.service.consolePort }}
|
||||
targetPort: console
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
serviceName: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{ include "tline.selectorLabels" . | indent 6 }}
|
||||
app.kubernetes.io/component: minio
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: minio
|
||||
image: {{ printf "%s:%s" .Values.images.minio.repository .Values.images.minio.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.images.minio.pullPolicy }}
|
||||
args:
|
||||
- server
|
||||
- /data
|
||||
- "--console-address=:{{ .Values.minio.service.consolePort }}"
|
||||
ports:
|
||||
- name: s3
|
||||
containerPort: 9000
|
||||
- name: console
|
||||
containerPort: 9001
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /minio/health/ready
|
||||
port: s3
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /minio/health/live
|
||||
port: s3
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{ toYaml .Values.minio.resources | indent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- $sc := include "tline.storageClass" (dict "Values" .Values "storageClass" .Values.minio.storage.storageClass) -}}
|
||||
{{- if $sc }}
|
||||
storageClassName: {{ $sc | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.minio.storage.size | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,100 @@
|
||||
{{- if .Values.postgres.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: postgres
|
||||
port: {{ .Values.postgres.service.port }}
|
||||
targetPort: postgres
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
serviceName: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{ include "tline.selectorLabels" . | indent 6 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: postgres
|
||||
image: {{ printf "%s:%s" .Values.images.postgres.repository .Values.images.postgres.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.images.postgres.pullPolicy }}
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: {{ .Values.secrets.postgres.user | quote }}
|
||||
- name: POSTGRES_DB
|
||||
value: {{ .Values.secrets.postgres.database | quote }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{ toYaml .Values.postgres.resources | indent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- $sc := include "tline.storageClass" (dict "Values" .Values "storageClass" .Values.postgres.storage.storageClass) -}}
|
||||
{{- if $sc }}
|
||||
storageClassName: {{ $sc | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgres.storage.size | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{- if .Values.redis.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: redis
|
||||
port: {{ .Values.redis.service.port }}
|
||||
targetPort: redis
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{ include "tline.selectorLabels" . | indent 6 }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.redis.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.redis.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: redis
|
||||
image: {{ printf "%s:%s" .Values.images.redis.repository .Values.images.redis.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.images.redis.pullPolicy }}
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
resources:
|
||||
{{ toYaml .Values.redis.resources | indent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if not .Values.secrets.existingSecret -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
POSTGRES_PASSWORD: {{ required "secrets.postgres.password is required" .Values.secrets.postgres.password | b64enc }}
|
||||
MINIO_ACCESS_KEY_ID: {{ required "secrets.minio.accessKeyId is required" .Values.secrets.minio.accessKeyId | b64enc }}
|
||||
MINIO_SECRET_ACCESS_KEY: {{ required "secrets.minio.secretAccessKey is required" .Values.secrets.minio.secretAccessKey | b64enc }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.registrySecret.create -}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "tline.registrySecretName" . }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
{{ $server := required "registrySecret.server is required" .Values.registrySecret.server -}}
|
||||
{{ $user := .Values.registrySecret.username | default "" -}}
|
||||
{{ $pass := required "registrySecret.password is required" .Values.registrySecret.password -}}
|
||||
{{ $email := .Values.registrySecret.email | default "" -}}
|
||||
{{ $auth := printf "%s:%s" $user $pass | b64enc -}}
|
||||
{{ $cfg := dict "auths" (dict $server (dict "username" $user "password" $pass "email" $email "auth" $auth)) -}}
|
||||
data:
|
||||
.dockerconfigjson: {{ $cfg | toJson | b64enc }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.tailscaleServiceConsole.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-ts-console") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
tailscale.com/hostname: {{ .Values.minio.tailscaleServiceConsole.hostnameLabel | quote }}
|
||||
{{- if .Values.minio.tailscaleServiceConsole.tags }}
|
||||
tailscale.com/tags: {{ join "," .Values.minio.tailscaleServiceConsole.tags | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.minio.tailscaleServiceConsole.extraAnnotations }}
|
||||
{{ toYaml . | indent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
loadBalancerClass: tailscale
|
||||
ports:
|
||||
- name: console
|
||||
port: 443
|
||||
targetPort: console
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
{{- end }}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.tailscaleServiceS3.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-ts-s3") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
tailscale.com/hostname: {{ .Values.minio.tailscaleServiceS3.hostnameLabel | quote }}
|
||||
{{- if .Values.minio.tailscaleServiceS3.tags }}
|
||||
tailscale.com/tags: {{ join "," .Values.minio.tailscaleServiceS3.tags | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.minio.tailscaleServiceS3.extraAnnotations }}
|
||||
{{ toYaml . | indent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
loadBalancerClass: tailscale
|
||||
ports:
|
||||
- name: s3
|
||||
port: 443
|
||||
targetPort: s3
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: minio
|
||||
{{- end }}
|
||||
@@ -0,0 +1,89 @@
|
||||
{{- if .Values.web.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.web.service.port }}
|
||||
targetPort: http
|
||||
selector:
|
||||
{{ include "tline.selectorLabels" . | indent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{ include "tline.selectorLabels" . | indent 6 }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.web.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.web.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: web
|
||||
image: {{ include "tline.image" (dict "repository" .Values.images.web.repository "tag" .Values.images.web.tag) | quote }}
|
||||
imagePullPolicy: {{ .Values.images.web.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{ toYaml .Values.web.resources | indent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{- if .Values.worker.enabled -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "worker") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: worker
|
||||
spec:
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{ include "tline.selectorLabels" . | indent 6 }}
|
||||
app.kubernetes.io/component: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: worker
|
||||
spec:
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.worker.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.worker.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker
|
||||
image: {{ include "tline.image" (dict "repository" .Values.images.worker.repository "tag" .Values.images.worker.tag) | quote }}
|
||||
imagePullPolicy: {{ .Values.images.worker.pullPolicy }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
{{ toYaml .Values.worker.resources | indent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,254 @@
|
||||
global:
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
storageClass: ""
|
||||
|
||||
tailscale:
|
||||
enabled: true
|
||||
ingressClassName: tailscale
|
||||
tailnetFQDN: "" # e.g. tailxyz.ts.net
|
||||
proxyGroup:
|
||||
enabled: false
|
||||
create: false
|
||||
name: ingress-proxies
|
||||
tags: []
|
||||
|
||||
scheduling:
|
||||
compute:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
tolerations: []
|
||||
|
||||
app:
|
||||
name: porthole
|
||||
queueName: tline
|
||||
|
||||
# Optional overrides when bringing your own services.
|
||||
databaseUrl: "" # defaults to in-chart Postgres when empty
|
||||
redisUrl: "" # defaults to in-chart Redis when empty
|
||||
|
||||
minio:
|
||||
bucket: media
|
||||
region: us-east-1
|
||||
presignExpiresSeconds: 900
|
||||
publicEndpointTs: "" # optional if global.tailscale.tailnetFQDN is set (defaults to https://minio.<tailnet-fqdn>)
|
||||
internalEndpoint: "" # defaults to in-chart MinIO when empty
|
||||
|
||||
secrets:
|
||||
existingSecret: "" # if set, chart will not create secrets
|
||||
|
||||
postgres:
|
||||
user: tline
|
||||
password: "" # REQUIRED if not using existingSecret
|
||||
database: tline
|
||||
|
||||
minio:
|
||||
accessKeyId: "" # REQUIRED if not using existingSecret
|
||||
secretAccessKey: "" # REQUIRED if not using existingSecret
|
||||
|
||||
images:
|
||||
web:
|
||||
repository: ""
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
worker:
|
||||
repository: ""
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
postgres:
|
||||
repository: postgres
|
||||
tag: "16"
|
||||
pullPolicy: IfNotPresent
|
||||
redis:
|
||||
repository: redis
|
||||
tag: "7"
|
||||
pullPolicy: IfNotPresent
|
||||
minio:
|
||||
repository: minio/minio
|
||||
tag: RELEASE.2024-01-16T16-07-38Z
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional: secrets used for pulling container images.
|
||||
# Each entry is a Secret name in the same namespace.
|
||||
imagePullSecrets: []
|
||||
|
||||
# Optional: create a docker-registry Secret from values.
|
||||
# This is convenient for private/local deployments, but stores credentials in values.
|
||||
registrySecret:
|
||||
create: false
|
||||
name: "" # defaults to <release>-<chart>-registry
|
||||
server: "" # e.g. registry.lan:5000
|
||||
username: ""
|
||||
password: ""
|
||||
email: ""
|
||||
|
||||
web:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
schedulingClass: compute
|
||||
service:
|
||||
port: 3000
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
hostnameLabel: app
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tags: []
|
||||
funnel: false
|
||||
extraAnnotations: {}
|
||||
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 1
|
||||
schedulingClass: compute
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
|
||||
postgres:
|
||||
enabled: true
|
||||
schedulingClass: compute
|
||||
service:
|
||||
port: 5432
|
||||
storage:
|
||||
size: 20Gi
|
||||
storageClass: "" # defaults to global.storageClass
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 2Gi
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
schedulingClass: compute
|
||||
service:
|
||||
port: 6379
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 512Mi
|
||||
|
||||
minio:
|
||||
enabled: true
|
||||
schedulingClass: compute
|
||||
service:
|
||||
s3Port: 9000
|
||||
consolePort: 9001
|
||||
|
||||
# Optional: expose MinIO S3 API via a Tailscale LoadBalancer Service
|
||||
# instead of (or in addition to) Tailscale Ingress.
|
||||
# This can be more reliable for streaming / Range requests depending on
|
||||
# Tailscale operator + cluster behavior.
|
||||
tailscaleServiceS3:
|
||||
enabled: false
|
||||
hostnameLabel: minio
|
||||
tags: []
|
||||
extraAnnotations: {}
|
||||
|
||||
tailscaleServiceConsole:
|
||||
enabled: false
|
||||
hostnameLabel: minio-console
|
||||
tags: []
|
||||
extraAnnotations: {}
|
||||
|
||||
# When true, disable the MinIO S3 Ingress when S3 service is enabled.
|
||||
ingressS3DisabledWhenTailscaleService: true
|
||||
|
||||
# When true, disable the MinIO console Ingress when console service is enabled.
|
||||
ingressConsoleDisabledWhenTailscaleService: true
|
||||
|
||||
storage:
|
||||
size: 200Gi
|
||||
storageClass: "" # defaults to global.storageClass
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 2Gi
|
||||
|
||||
ingressS3:
|
||||
enabled: true
|
||||
hostnameLabel: minio
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tags: []
|
||||
funnel: false
|
||||
extraAnnotations: {}
|
||||
|
||||
ingressConsole:
|
||||
enabled: true
|
||||
hostnameLabel: minio-console
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tags: []
|
||||
funnel: false
|
||||
extraAnnotations: {}
|
||||
|
||||
jobs:
|
||||
ensureBucket:
|
||||
enabled: false
|
||||
image:
|
||||
repository: minio/mc
|
||||
tag: RELEASE.2024-01-16T16-07-38Z
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
|
||||
migrate:
|
||||
enabled: true
|
||||
image:
|
||||
repository: "" # defaults to images.worker.repository when empty
|
||||
tag: "" # defaults to images.worker.tag when empty
|
||||
pullPolicy: "" # defaults to images.worker.pullPolicy when empty
|
||||
|
||||
cronjobs:
|
||||
cleanupStaging:
|
||||
enabled: false
|
||||
schedule: "0 4 * * *"
|
||||
# Remove objects under `staging/` older than this many days.
|
||||
# This CronJob must never touch `originals/`.
|
||||
olderThanDays: 14
|
||||
image:
|
||||
repository: minio/mc
|
||||
tag: RELEASE.2024-01-16T16-07-38Z
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"agent": {
|
||||
"orchestrator": {
|
||||
"description": "Backlog coordination, contracts, and acceptance criteria",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/gpt-5.2",
|
||||
"temperature": 0.2,
|
||||
"prompt": "You are the orchestrator for this repo. Keep PLAN.md as the source of truth. Align DB/API/worker/UI contracts, surface inconsistencies, and produce concise checklists and decisions. Prefer minimal MVP scope. Enforce: never delete/mutate originals/ and presigned URLs must target MINIO_PUBLIC_ENDPOINT_TS with path-style URLs.",
|
||||
"tools": {
|
||||
"edit": false,
|
||||
"write": false
|
||||
},
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": {
|
||||
"git status": "allow",
|
||||
"git diff*": "allow",
|
||||
"git log*": "allow",
|
||||
"*": "ask"
|
||||
},
|
||||
"webfetch": "allow"
|
||||
}
|
||||
},
|
||||
"backend-api": {
|
||||
"description": "Next.js API routes, DB schema/migrations, MinIO presigned URL logic",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
"temperature": 0.3,
|
||||
"prompt": "You implement backend/API work for the timeline media library per PLAN.md. Be strict about request validation (zod), implement correct presigned URL behavior (path-style, MINIO_PUBLIC_ENDPOINT_TS), and respect the external originals policy (never delete/mutate originals/). Prefer small, reviewable changes.",
|
||||
"tools": {
|
||||
"bash": true,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list": true,
|
||||
"edit": true,
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"worker-media": {
|
||||
"description": "BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
"temperature": 0.3,
|
||||
"prompt": "You implement the worker/media pipeline per PLAN.md: scan_minio_prefix, process_asset, copy_to_canonical. Be robust: never crash the worker loop; update assets.status=failed and error_message on failures. Keep concurrency low (Pi-friendly). Never delete/mutate originals/; canonical copy is copy-only.",
|
||||
"tools": {
|
||||
"bash": true,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list": true,
|
||||
"edit": true,
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"frontend-ui": {
|
||||
"description": "Timeline tree rendering, responsive layout, virtualization, styling",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/gpt-5.2",
|
||||
"temperature": 0.4,
|
||||
"prompt": "You implement the frontend UI per PLAN.md using Next.js App Router. Build the interactive timeline tree (orientation toggle, zoom/pan, expand/collapse), mobile-friendly layout (bottom sheet), virtualized thumbnails, and viewer modal. The UI must never crash on failed assets; render placeholders.",
|
||||
"tools": {
|
||||
"bash": true,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list": true,
|
||||
"edit": true,
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"k8s-infra": {
|
||||
"description": "Helm/Kustomize, node affinity, MinIO/Postgres/Redis manifests, Tailscale ingress",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
"temperature": 0.3,
|
||||
"prompt": "You implement Pi-aware Kubernetes deployment artifacts per PLAN.md. Pin heavy pods to Pi 5 nodes via affinity/labels, provision Longhorn PVCs for Postgres/MinIO, and configure Tailscale ingress for app/minio/console. Ensure video Range requests work. Never include secrets; use placeholders and document required env vars.",
|
||||
"tools": {
|
||||
"bash": true,
|
||||
"read": true,
|
||||
"grep": true,
|
||||
"glob": true,
|
||||
"list": true,
|
||||
"edit": true,
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"qa-review": {
|
||||
"description": "Test plan, edge cases, security review, performance checks",
|
||||
"mode": "primary",
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
"temperature": 0.2,
|
||||
"prompt": "You perform targeted QA review per PLAN.md. Focus on edge cases (missing EXIF, timezones, corrupt files, unsupported codecs) and resilience (UI never crashes). Check presigned URLs are HTTPS and use MINIO_PUBLIC_ENDPOINT_TS; confirm Range video playback through ingress. Suggest minimal tests if a harness exists.",
|
||||
"tools": {
|
||||
"edit": false,
|
||||
"write": false
|
||||
},
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": {
|
||||
"git status": "allow",
|
||||
"git diff*": "allow",
|
||||
"git log*": "allow",
|
||||
"*": "ask"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "tline",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Timeline media library (porthole)",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "bunx tsc -p packages/config/tsconfig.json --noEmit && bunx tsc -p packages/db/tsconfig.json --noEmit && bunx tsc -p packages/minio/tsconfig.json --noEmit && bunx tsc -p packages/queue/tsconfig.json --noEmit && bunx tsc -p apps/worker/tsconfig.json --noEmit && bunx tsc -p apps/web/tsconfig.json --noEmit",
|
||||
"lint": "bunx eslint .",
|
||||
"format": "bunx prettier . --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"bun-types": "^1.3.5",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@tline/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
APP_NAME: z.string().min(1).default("porthole"),
|
||||
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional()
|
||||
});
|
||||
|
||||
let cachedEnv: z.infer<typeof envSchema> | undefined;
|
||||
|
||||
export function getEnv() {
|
||||
if (cachedEnv) return cachedEnv;
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid environment variables: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
cachedEnv = parsed.data;
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
export function getAppName() {
|
||||
const env = getEnv();
|
||||
return env.NEXT_PUBLIC_APP_NAME ?? env.APP_NAME;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Task 2 (MVP): assets/imports schema
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE media_type AS ENUM ('image', 'video');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE asset_status AS ENUM ('new', 'processing', 'ready', 'failed');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE date_confidence AS ENUM ('camera', 'container', 'object_mtime', 'import_time');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE import_type AS ENUM ('upload', 'minio_scan', 'normalize_copy');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type import_type NOT NULL,
|
||||
status text NOT NULL DEFAULT 'new',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
total_count int,
|
||||
processed_count int,
|
||||
failed_count int
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bucket text NOT NULL,
|
||||
media_type media_type NOT NULL,
|
||||
mime_type text NOT NULL,
|
||||
|
||||
source_key text NOT NULL,
|
||||
active_key text NOT NULL,
|
||||
canonical_key text,
|
||||
|
||||
capture_ts_utc timestamptz,
|
||||
capture_offset_minutes int,
|
||||
date_confidence date_confidence,
|
||||
|
||||
width int,
|
||||
height int,
|
||||
rotation int,
|
||||
duration_seconds double precision,
|
||||
|
||||
thumb_small_key text,
|
||||
thumb_med_key text,
|
||||
poster_key text,
|
||||
|
||||
status asset_status NOT NULL DEFAULT 'new',
|
||||
error_message text,
|
||||
raw_tags_json jsonb,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS assets_source_key_idx ON assets (source_key);
|
||||
CREATE INDEX IF NOT EXISTS assets_capture_ts_idx ON assets (capture_ts_utc);
|
||||
CREATE INDEX IF NOT EXISTS assets_status_idx ON assets (status);
|
||||
CREATE INDEX IF NOT EXISTS assets_media_type_idx ON assets (media_type);
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER assets_set_updated_at
|
||||
BEFORE UPDATE ON assets
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Task 2 follow-up: align schema with PLAN.md
|
||||
|
||||
-- 1) duration_seconds should be int (seconds)
|
||||
ALTER TABLE assets
|
||||
ALTER COLUMN duration_seconds
|
||||
TYPE int
|
||||
USING (
|
||||
CASE
|
||||
WHEN duration_seconds IS NULL THEN NULL
|
||||
ELSE round(duration_seconds)::int
|
||||
END
|
||||
);
|
||||
|
||||
-- 2) source_key uniqueness should be per-bucket
|
||||
DROP INDEX IF EXISTS assets_source_key_idx;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS assets_bucket_source_key_uidx ON assets (bucket, source_key);
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tline/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"migrate": "bun run src/migrate.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1)
|
||||
});
|
||||
|
||||
let cachedDb: Sql | undefined;
|
||||
|
||||
export function getDb() {
|
||||
if (cachedDb) return cachedDb;
|
||||
|
||||
const env = envSchema.parse(process.env);
|
||||
cachedDb = postgres(env.DATABASE_URL);
|
||||
return cachedDb;
|
||||
}
|
||||
|
||||
export async function closeDb() {
|
||||
if (!cachedDb) return;
|
||||
const db = cachedDb;
|
||||
cachedDb = undefined;
|
||||
await db.end({ timeout: 5 });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1)
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const env = envSchema.parse(process.env);
|
||||
const sql = postgres(env.DATABASE_URL, { max: 1 });
|
||||
|
||||
try {
|
||||
await sql`CREATE TABLE IF NOT EXISTS schema_migrations (id text primary key, applied_at timestamptz not null default now())`;
|
||||
|
||||
const migrationsDir = path.join(import.meta.dir, "..", "migrations");
|
||||
const files = (await readdir(migrationsDir)).filter((f) => f.endsWith(".sql")).sort();
|
||||
|
||||
for (const file of files) {
|
||||
const already = await sql<{ id: string }[]>`SELECT id FROM schema_migrations WHERE id = ${file}`;
|
||||
if (already.length > 0) continue;
|
||||
|
||||
const contents = await readFile(path.join(migrationsDir, file), "utf8");
|
||||
await sql.begin(async (tx) => {
|
||||
await tx.unsafe(contents);
|
||||
await tx`INSERT INTO schema_migrations (id) VALUES (${file})`;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Applied migration ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "migrations/**/*.sql"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@tline/minio",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.899.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.899.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import "server-only";
|
||||
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
|
||||
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
|
||||
MINIO_ACCESS_KEY_ID: z.string().min(1),
|
||||
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
|
||||
MINIO_REGION: z.string().min(1).default("us-east-1"),
|
||||
MINIO_BUCKET: z.string().min(1).default("media"),
|
||||
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce.number().int().positive().default(900)
|
||||
});
|
||||
|
||||
type MinioEnv = z.infer<typeof envSchema>;
|
||||
|
||||
let cachedEnv: MinioEnv | undefined;
|
||||
let cachedInternal: S3Client | undefined;
|
||||
let cachedPublic: S3Client | undefined;
|
||||
|
||||
export function getMinioEnv(): MinioEnv {
|
||||
if (cachedEnv) return cachedEnv;
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid MinIO env: ${parsed.error.message}`);
|
||||
}
|
||||
cachedEnv = parsed.data;
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
export function getMinioBucket() {
|
||||
return getMinioEnv().MINIO_BUCKET;
|
||||
}
|
||||
|
||||
export function getMinioInternalClient(): S3Client {
|
||||
if (cachedInternal) return cachedInternal;
|
||||
const env = getMinioEnv();
|
||||
if (!env.MINIO_INTERNAL_ENDPOINT) {
|
||||
throw new Error("MINIO_INTERNAL_ENDPOINT is required for internal MinIO client");
|
||||
}
|
||||
|
||||
cachedInternal = new S3Client({
|
||||
region: env.MINIO_REGION,
|
||||
endpoint: env.MINIO_INTERNAL_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: env.MINIO_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
return cachedInternal;
|
||||
}
|
||||
|
||||
export function getMinioPublicSigningClient(): S3Client {
|
||||
if (cachedPublic) return cachedPublic;
|
||||
const env = getMinioEnv();
|
||||
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
|
||||
throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation");
|
||||
}
|
||||
|
||||
cachedPublic = new S3Client({
|
||||
region: env.MINIO_REGION,
|
||||
endpoint: env.MINIO_PUBLIC_ENDPOINT_TS,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: env.MINIO_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
return cachedPublic;
|
||||
}
|
||||
|
||||
export async function presignGetObjectUrl(input: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresSeconds?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}) {
|
||||
const env = getMinioEnv();
|
||||
const s3 = getMinioPublicSigningClient();
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: input.bucket,
|
||||
Key: input.key,
|
||||
ResponseContentType: input.responseContentType,
|
||||
ResponseContentDisposition: input.responseContentDisposition,
|
||||
});
|
||||
|
||||
const expiresIn = input.expiresSeconds ?? env.MINIO_PRESIGN_EXPIRES_SECONDS;
|
||||
const url = await getSignedUrl(s3, command, { expiresIn });
|
||||
return { url, expiresSeconds: expiresIn };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@tline/queue",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"bullmq": "^5.61.0",
|
||||
"ioredis": "^5.8.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
|
||||
const envSchema = z.object({
|
||||
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
|
||||
QUEUE_NAME: z.string().min(1).default("tline")
|
||||
});
|
||||
|
||||
export const jobNameSchema = z.enum([
|
||||
"scan_minio_prefix",
|
||||
"process_asset",
|
||||
"copy_to_canonical"
|
||||
]);
|
||||
|
||||
export type QueueJobName = z.infer<typeof jobNameSchema>;
|
||||
|
||||
export const scanMinioPrefixPayloadSchema = z
|
||||
.object({
|
||||
importId: z.string().uuid(),
|
||||
bucket: z.string().min(1),
|
||||
prefix: z.string().min(1)
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const processAssetPayloadSchema = z
|
||||
.object({
|
||||
assetId: z.string().uuid()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const copyToCanonicalPayloadSchema = z
|
||||
.object({
|
||||
assetId: z.string().uuid()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const payloadByJobNameSchema = z.discriminatedUnion("name", [
|
||||
z.object({ name: z.literal("scan_minio_prefix"), payload: scanMinioPrefixPayloadSchema }),
|
||||
z.object({ name: z.literal("process_asset"), payload: processAssetPayloadSchema }),
|
||||
z.object({ name: z.literal("copy_to_canonical"), payload: copyToCanonicalPayloadSchema })
|
||||
]);
|
||||
|
||||
export type ScanMinioPrefixPayload = z.infer<typeof scanMinioPrefixPayloadSchema>;
|
||||
export type ProcessAssetPayload = z.infer<typeof processAssetPayloadSchema>;
|
||||
export type CopyToCanonicalPayload = z.infer<typeof copyToCanonicalPayloadSchema>;
|
||||
|
||||
type QueueEnv = z.infer<typeof envSchema>;
|
||||
|
||||
let cachedEnv: QueueEnv | undefined;
|
||||
let cachedRedis: IORedis | undefined;
|
||||
let cachedQueue: Queue | undefined;
|
||||
|
||||
export function getQueueEnv(): QueueEnv {
|
||||
if (cachedEnv) return cachedEnv;
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid queue env: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
cachedEnv = parsed.data;
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
export function getQueueName() {
|
||||
return getQueueEnv().QUEUE_NAME;
|
||||
}
|
||||
|
||||
export function getRedis() {
|
||||
if (cachedRedis) return cachedRedis;
|
||||
const env = getQueueEnv();
|
||||
cachedRedis = new IORedis(env.REDIS_URL, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: null
|
||||
});
|
||||
|
||||
cachedRedis.on("error", () => {});
|
||||
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
export function getQueue() {
|
||||
if (cachedQueue) return cachedQueue;
|
||||
getQueueEnv();
|
||||
|
||||
cachedQueue = new Queue(getQueueName(), {
|
||||
connection: getRedis()
|
||||
});
|
||||
return cachedQueue;
|
||||
}
|
||||
|
||||
export async function closeQueue() {
|
||||
await Promise.all([
|
||||
cachedQueue?.close(),
|
||||
cachedRedis?.quit().catch(() => cachedRedis?.disconnect())
|
||||
]);
|
||||
cachedQueue = undefined;
|
||||
cachedRedis = undefined;
|
||||
}
|
||||
|
||||
export async function enqueueScanMinioPrefix(input: ScanMinioPrefixPayload) {
|
||||
const payload = scanMinioPrefixPayloadSchema.parse(input);
|
||||
const queue = getQueue();
|
||||
return queue.add("scan_minio_prefix", payload, {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 1000 }
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueProcessAsset(input: ProcessAssetPayload) {
|
||||
const payload = processAssetPayloadSchema.parse(input);
|
||||
const queue = getQueue();
|
||||
return queue.add("process_asset", payload, {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 1000 }
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueCopyToCanonical(input: CopyToCanonicalPayload) {
|
||||
const payload = copyToCanonicalPayloadSchema.parse(input);
|
||||
const queue = getQueue();
|
||||
return queue.add("copy_to_canonical", payload, {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 1000 }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@tline/*": ["packages/*/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user