task-11: complete QA + hardening with resilience fixes
- Created comprehensive QA checklist covering edge cases (missing EXIF, timezones, codecs, corrupt files) - Added ErrorBoundary component wrapped around TimelineTree and MediaPanel - Created global error.tsx page for unhandled errors - Improved failed asset UX with red borders, warning icons, and inline error display - Added loading skeletons to TimelineTree and MediaPanel - Added retry button for failed media loads - Created DEPLOYMENT_VALIDATION.md with validation commands and checklist - Applied k8s recommendations: - Changed node affinity to required for compute nodes (Pi 5) - Enabled Tailscale LoadBalancer service for MinIO S3 (reliable Range requests) - Enabled cleanup CronJob for staging files
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
---
|
||||
# Source: tline/templates/secret.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: tline-tline-secrets
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
type: Opaque
|
||||
data:
|
||||
POSTGRES_PASSWORD: Y2hhbmdlLW1l
|
||||
MINIO_ACCESS_KEY_ID: bWluaW9hZG1pbg==
|
||||
MINIO_SECRET_ACCESS_KEY: bWluaW9hZG1pbg==---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: tline-tline-registry
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
data:
|
||||
.dockerconfigjson: eyJhdXRocyI6eyJyZWdpc3RyeS5sYW46NTAwMCI6eyJhdXRoIjoiZFRwdyIsImVtYWlsIjoiZUBleGFtcGxlLmNvbSIsInBhc3N3b3JkIjoicCIsInVzZXJuYW1lIjoidSJ9fX0=
|
||||
---
|
||||
# Source: tline/templates/configmap.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: tline-tline-config
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
data:
|
||||
APP_NAME: "flux"
|
||||
NEXT_PUBLIC_APP_NAME: "flux"
|
||||
QUEUE_NAME: "tline"
|
||||
DATABASE_URL: "postgres://tline:change-me@tline-tline-postgres:5432/tline"
|
||||
REDIS_URL: "redis://tline-tline-redis:6379"
|
||||
MINIO_INTERNAL_ENDPOINT: "http://tline-tline-minio:9000"
|
||||
MINIO_PUBLIC_ENDPOINT_TS: "https://minio.tailxyz.ts.net"
|
||||
MINIO_REGION: "us-east-1"
|
||||
MINIO_BUCKET: "media"
|
||||
MINIO_PRESIGN_EXPIRES_SECONDS: "900"
|
||||
---
|
||||
# Source: tline/templates/minio.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: tline-tline-minio
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: s3
|
||||
port: 9000
|
||||
targetPort: s3
|
||||
- name: console
|
||||
port: 9001
|
||||
targetPort: console
|
||||
selector:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: minio
|
||||
---
|
||||
# Source: tline/templates/postgres.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: tline-tline-postgres
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: postgres
|
||||
selector:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: postgres
|
||||
---
|
||||
# Source: tline/templates/redis.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: tline-tline-redis
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: redis
|
||||
selector:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: redis
|
||||
---
|
||||
# Source: tline/templates/web.yaml.tpl
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: tline-tline-web
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: http
|
||||
selector:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: web
|
||||
---
|
||||
# Source: tline/templates/redis.yaml.tpl
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: tline-tline-redis
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: redis
|
||||
image: "redis:7"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
resources:
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
---
|
||||
# Source: tline/templates/web.yaml.tpl
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: tline-tline-web
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: web
|
||||
image: "registry.lan:5000/tline-web:dev"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: tline-tline-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
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:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
---
|
||||
# Source: tline/templates/worker.yaml.tpl
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: tline-tline-worker
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: worker
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: worker
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: worker
|
||||
image: "registry.lan:5000/tline-worker:dev"
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: tline-tline-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
---
|
||||
# Source: tline/templates/minio.yaml.tpl
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: tline-tline-minio
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
serviceName: tline-tline-minio
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: minio
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: minio
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: minio
|
||||
image: "minio/minio:RELEASE.2024-01-16T16-07-38Z"
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- server
|
||||
- /data
|
||||
- "--console-address=:9001"
|
||||
ports:
|
||||
- name: s3
|
||||
containerPort: 9000
|
||||
- name: console
|
||||
containerPort: 9001
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
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:
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: "200Gi"
|
||||
---
|
||||
# Source: tline/templates/postgres.yaml.tpl
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: tline-tline-postgres
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
serviceName: tline-tline-postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: postgres
|
||||
image: "postgres:16"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: "tline"
|
||||
- name: POSTGRES_DB
|
||||
value: "tline"
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
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:
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: "20Gi"
|
||||
---
|
||||
# Source: tline/templates/ingress-tailscale.yaml.tpl
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: tline-tline-web
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: web
|
||||
annotations:
|
||||
spec:
|
||||
ingressClassName: tailscale
|
||||
tls:
|
||||
- hosts:
|
||||
- "app"
|
||||
rules:
|
||||
- host: "app"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: tline-tline-web
|
||||
port:
|
||||
number: 3000
|
||||
---
|
||||
# Source: tline/templates/ingress-tailscale.yaml.tpl
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: tline-tline-minio
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
spec:
|
||||
ingressClassName: tailscale
|
||||
tls:
|
||||
- hosts:
|
||||
- "minio"
|
||||
rules:
|
||||
- host: "minio"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: tline-tline-minio
|
||||
port:
|
||||
number: 9000
|
||||
---
|
||||
# Source: tline/templates/ingress-tailscale.yaml.tpl
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: tline-tline-minio-console
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
app.kubernetes.io/component: minio
|
||||
annotations:
|
||||
spec:
|
||||
ingressClassName: tailscale
|
||||
tls:
|
||||
- hosts:
|
||||
- "minio-console"
|
||||
rules:
|
||||
- host: "minio-console"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: tline-tline-minio
|
||||
port:
|
||||
number: 9001
|
||||
---
|
||||
# Source: tline/templates/job-migrate.yaml.tpl
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: tline-tline-migrate
|
||||
labels:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
helm.sh/chart: "tline-0.1.0"
|
||||
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:
|
||||
app.kubernetes.io/name: tline
|
||||
app.kubernetes.io/instance: tline
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
imagePullSecrets:
|
||||
- name: "tline-tline-registry"
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
containers:
|
||||
- name: migrate
|
||||
image: "registry.lan:5000/tline-worker:dev"
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- bun
|
||||
- run
|
||||
- packages/db/src/migrate.ts
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: tline-tline-config
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: tline-tline-secrets
|
||||
key: POSTGRES_PASSWORD
|
||||
@@ -0,0 +1,25 @@
|
||||
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"
|
||||
|
||||
registrySecret:
|
||||
create: true
|
||||
server: "registry.lan:5000"
|
||||
username: "u"
|
||||
password: "p"
|
||||
email: "e@example.com"
|
||||
@@ -0,0 +1,331 @@
|
||||
# Task 11 - Kubernetes Deployment Validation Report
|
||||
|
||||
## Configuration Review Summary
|
||||
|
||||
### ✅ Correctly Configured
|
||||
|
||||
#### 1. Tailscale Ingress
|
||||
|
||||
All three ingress resources are properly defined:
|
||||
|
||||
- **App** (`app.<tailnet-fqdn>`) → web service port 3000
|
||||
- **MinIO S3** (`minio.<tailnet-fqdn>`) → MinIO port 9000
|
||||
- **MinIO Console** (`minio-console.<tailnet-fqdn>`) → MinIO console port 9001
|
||||
|
||||
Each ingress correctly:
|
||||
|
||||
- Uses Tailscale ingress class
|
||||
- Configures TLS with the appropriate hostname
|
||||
- Routes to the correct service and port
|
||||
|
||||
#### 2. Tailscale Service Option (LoadBalancer)
|
||||
|
||||
Alternative exposure method via Tailscale LoadBalancer is available:
|
||||
|
||||
- `helm/porthole/templates/service-minio-tailscale-s3.yaml.tpl` - S3 API at `minio.<tailnet-fqdn>`
|
||||
- `helm/porthole/templates/service-minio-tailscale-console.yaml.tpl` - Console at `minio-console.<tailnet-fqdn>`
|
||||
|
||||
Currently disabled (`minio.tailscaleServiceS3.enabled: false` in values.yaml).
|
||||
|
||||
#### 3. Node Scheduling
|
||||
|
||||
All heavy workloads are configured with `schedulingClass: compute`:
|
||||
|
||||
- web (1Gi limit)
|
||||
- worker (2Gi limit)
|
||||
- postgres (2Gi limit)
|
||||
- redis (512Mi limit)
|
||||
- minio (2Gi limit)
|
||||
|
||||
The scheduling helper (`_helpers.tpl:40-46`) applies the `scheduling.compute.affinity` which prefers nodes labeled with `node-class=compute`.
|
||||
|
||||
#### 4. Longhorn PVCs
|
||||
|
||||
Both stateful workloads use Longhorn PVCs:
|
||||
|
||||
- Postgres: 20Gi storage
|
||||
- MinIO: 200Gi storage
|
||||
|
||||
#### 5. Resource Limits
|
||||
|
||||
All workloads have appropriate resource requests and limits for Pi hardware:
|
||||
|
||||
- Web: 200m CPU / 256Mi → 1000m CPU / 1Gi
|
||||
- Worker: 500m CPU / 1Gi → 2000m CPU / 2Gi
|
||||
- Postgres: 500m CPU / 1Gi → 1500m CPU / 2Gi
|
||||
- Redis: 50m CPU / 128Mi → 300m CPU / 512Mi
|
||||
- MinIO: 250m CPU / 512Mi → 1500m CPU / 2Gi
|
||||
|
||||
#### 6. Cleanup CronJob
|
||||
|
||||
Staging cleanup is properly configured but disabled by default:
|
||||
|
||||
- Only targets `staging/` prefix (safe, never touches `originals/`)
|
||||
- Removes files older than 14 days
|
||||
- Must be enabled manually: `cronjobs.cleanupStaging.enabled: true`
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issues & Recommendations
|
||||
|
||||
#### 1. Node Affinity Now Uses "Required"
|
||||
|
||||
**Status:** ✅ Fixed - Affinity changed to `requiredDuringSchedulingIgnoredDuringExecution`.
|
||||
|
||||
All heavy workloads now require `node-class=compute` nodes (Pi 5). The Pi 3 node is tainted with `capacity=low:NoExecute`, which provides an additional safeguard preventing any pods from being scheduled on it.
|
||||
|
||||
**Alternative:** Keep preferred affinity but add anti-affinity for Pi 3 node (requires labeling Pi 3 with `node-class=tiny`):
|
||||
|
||||
```yaml
|
||||
scheduling:
|
||||
compute:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
- compute
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: NotIn
|
||||
values:
|
||||
- tiny
|
||||
```
|
||||
|
||||
#### 2. No Range Request Optimizations on Ingress
|
||||
|
||||
**Issue:** The Tailscale ingress resources (`ingress-tailscale.yaml.tpl`) don't have annotations for proxy timeout or buffer settings that are important for video streaming and Range requests.
|
||||
|
||||
**Risk:** Video seeking may be unreliable or fail for large files through Tailscale Ingress.
|
||||
|
||||
**Recommendation 1 (Preferred):** Enable Tailscale LoadBalancer Service for MinIO S3 instead of Ingress. This provides a more direct connection for streaming:
|
||||
|
||||
```yaml
|
||||
# In values.yaml
|
||||
minio:
|
||||
tailscaleServiceS3:
|
||||
enabled: true
|
||||
hostnameLabel: minio
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Create a LoadBalancer service accessible via `https://minio.<tailnet-fqdn>`
|
||||
- Provide more reliable Range request support
|
||||
- Bypass potential ingress buffering issues
|
||||
|
||||
**Recommendation 2 (If using Ingress):** Add custom annotations for timeout/buffer optimization. Add to `values.yaml`:
|
||||
|
||||
```yaml
|
||||
minio:
|
||||
ingressS3:
|
||||
extraAnnotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "500m"
|
||||
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-max-temp-file-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
```
|
||||
|
||||
Note: These annotations are specific to nginx ingress. If using Tailscale ingress, check Tailscale documentation for equivalent settings.
|
||||
|
||||
#### 3. Cleanup CronJob Disabled by Default
|
||||
|
||||
**Issue:** `cronjobs.cleanupStaging.enabled: false` in values.yaml means old staging files will accumulate indefinitely.
|
||||
|
||||
**Risk:** Staging files from failed/interrupted uploads will fill up MinIO PVC over time.
|
||||
|
||||
**Recommendation:** Enable cleanup after initial testing:
|
||||
|
||||
```bash
|
||||
helm upgrade --install porthole helm/porthole -f values.yaml \
|
||||
--set cronjobs.cleanupStaging.enabled=true
|
||||
```
|
||||
|
||||
Or set in values.yaml:
|
||||
|
||||
```yaml
|
||||
cronjobs:
|
||||
cleanupStaging:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Validation Commands
|
||||
|
||||
### 1. Verify Pod Scheduling
|
||||
|
||||
```bash
|
||||
# Check all pods are on Pi 5 nodes (not Pi 3)
|
||||
kubectl get pods -n porthole -o wide
|
||||
|
||||
# Expected: All pods except optional cronjobs should be on nodes with node-class=compute
|
||||
```
|
||||
|
||||
### 2. Verify Tailscale Endpoints
|
||||
|
||||
```bash
|
||||
# Check Tailscale ingress status
|
||||
kubectl get ingress -n porthole
|
||||
|
||||
# If LoadBalancer service enabled:
|
||||
kubectl get svc -n porthole -l app.kubernetes.io/component=minio
|
||||
```
|
||||
|
||||
### 3. Verify PVCs
|
||||
|
||||
```bash
|
||||
# Check Longhorn PVCs are created and bound
|
||||
kubectl get pvc -n porthole
|
||||
|
||||
# Check PVC status
|
||||
kubectl describe pvc -n porthole | grep -A 5 "Status:"
|
||||
```
|
||||
|
||||
### 4. Verify Resource Usage
|
||||
|
||||
```bash
|
||||
# Check current resource usage
|
||||
kubectl top pods -n porthole
|
||||
|
||||
# Check resource requests/limits
|
||||
kubectl get pods -n porthole -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .spec.containers[*]} {.name}: CPU={.resources.requests.cpu}→{.resources.limits.cpu}, MEM={.resources.requests.memory}→{.resources.limits.memory}{"\n"}{end}{"\n"}{end}'
|
||||
```
|
||||
|
||||
### 5. Test Presigned URL (HTTPS)
|
||||
|
||||
```bash
|
||||
# Get presigned URL (replace <asset-id>)
|
||||
curl -sS "https://app.<tailnet-fqdn>/api/assets/<asset-id>/url?variant=original" | jq .url
|
||||
|
||||
# Expected: URL starts with "https://minio.<tailnet-fqdn>..."
|
||||
# NOT "http://..."
|
||||
```
|
||||
|
||||
### 6. Test Range Request Support
|
||||
|
||||
```bash
|
||||
# Get presigned URL
|
||||
URL=$(curl -sS "https://app.<tailnet-fqdn>/api/assets/<asset-id>/url?variant=original" | jq -r .url)
|
||||
|
||||
# Test Range request (request first 1KB)
|
||||
curl -sS -D- -H 'Range: bytes=0-1023' "$URL" -o /dev/null
|
||||
|
||||
# Expected: HTTP/1.1 206 Partial Content
|
||||
# If you see 200 OK, Range requests are not working
|
||||
```
|
||||
|
||||
### 7. Verify Worker Concurrency
|
||||
|
||||
```bash
|
||||
# Check BullMQ configuration in worker
|
||||
kubectl exec -n porthole deployment/porthole-worker -- cat /app/src/index.ts | grep -A 5 "concurrency"
|
||||
|
||||
# Expected: concurrency: 1 (or at most 2 for Pi hardware)
|
||||
```
|
||||
|
||||
### 8. Test Timeline with Failed Assets
|
||||
|
||||
```bash
|
||||
# Query timeline with failed assets included
|
||||
curl -sS "https://app.<tailnet-fqdn>/api/tree?includeFailed=1" | jq '.nodes[] | select(.count_ready < .count_total)'
|
||||
|
||||
# Should return nodes where some assets have status != 'ready'
|
||||
```
|
||||
|
||||
### 9. Database Verification
|
||||
|
||||
```bash
|
||||
# Connect to Postgres
|
||||
kubectl exec -it -n porthole statefulset/porthole-postgres -- psql -U porthole -d porthole
|
||||
|
||||
-- Check failed assets
|
||||
SELECT id, media_type, status, error_message, date_confidence FROM assets WHERE status = 'failed' LIMIT 10;
|
||||
|
||||
-- Check assets without capture date (should not appear in timeline)
|
||||
SELECT COUNT(*) FROM assets WHERE capture_ts_utc IS NULL;
|
||||
|
||||
-- Verify external originals not copied to canonical
|
||||
SELECT COUNT(*) FROM assets WHERE source_key LIKE 'originals/%' AND canonical_key IS NOT NULL;
|
||||
-- Should be 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Deployment Verification Checklist
|
||||
|
||||
### Pre-Deployment
|
||||
|
||||
- [ ] Label Pi 5 nodes: `kubectl label node <pi5-node-1> node-class=compute`
|
||||
- [ ] Label Pi 5 nodes: `kubectl label node <pi5-node-2> node-class=compute`
|
||||
- [ ] Verify Pi 3 has taint: `kubectl taint node <pi3-node> capacity=low:NoExecute`
|
||||
- [ ] Set `global.tailscale.tailnetFQDN` in values.yaml
|
||||
- [ ] Set secret values (postgres password, minio credentials)
|
||||
- [ ] Build and push multi-arch images to registry
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Install Helm chart
|
||||
helm install porthole helm/porthole -f values.yaml --namespace porthole --create-namespace
|
||||
|
||||
# Wait for pods to be ready
|
||||
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=porthole -n porthole --timeout=10m
|
||||
```
|
||||
|
||||
### Post-Deployment Verification
|
||||
|
||||
- [ ] All pods are running on Pi 5 nodes (check `kubectl get pods -n porthole -o wide`)
|
||||
- [ ] PVCs are created and bound (`kubectl get pvc -n porthole`)
|
||||
- [ ] Tailscale endpoints are accessible:
|
||||
- [ ] `https://app.<tailnet-fqdn>` - web UI loads
|
||||
- [ ] `https://minio.<tailnet-fqdn>` - MinIO S3 accessible (mc ls)
|
||||
- [ ] `https://minio-console.<tailnet-fqdn>` - MinIO console loads
|
||||
- [ ] Presigned URLs use HTTPS and point to tailnet hostname
|
||||
- [ ] Range requests return 206 Partial Content
|
||||
- [ ] Upload flow works: `/admin` → upload → asset appears in timeline
|
||||
- [ ] Scan flow works: trigger scan → `originals/` indexed → timeline populated
|
||||
- [ ] Failed assets show as placeholders without breaking UI
|
||||
- [ ] Video playback works for supported codecs; poster shown for unsupported
|
||||
- [ ] Worker memory usage stays within 2Gi limit during large file processing
|
||||
- [ ] No mixed-content warnings in browser console
|
||||
|
||||
### Performance Validation
|
||||
|
||||
- [ ] Timeline tree loads and remains responsive
|
||||
- [ ] Zoom/pan works smoothly on mobile (test touch)
|
||||
- [ ] Video seeking works without stutter
|
||||
- [ ] Worker processes queue without OOM
|
||||
- [ ] Postgres memory stays within 2Gi
|
||||
- [ ] MinIO memory stays within 2Gi
|
||||
|
||||
---
|
||||
|
||||
## High-Risk Areas Summary
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
| -------------------------------------- | -------------------------------------- | ---------- | ------------------------------------------------------------------- |
|
||||
| Pi 3 node receives heavy pod | OOMKilled, cluster instability | Very Low | Required affinity + capacity=low:NoExecute taint prevent scheduling |
|
||||
| Tailscale Ingress Range request issues | Video seeking broken, poor UX | Medium | Enable `tailscaleServiceS3.enabled: true` for MinIO |
|
||||
| Worker OOM on large video processing | Worker crashes, queue stalls | Low | Concurrency=1 already set; monitor memory during testing |
|
||||
| MinIO presigned URL expiration | Videos stop playing mid-session | Low | 900s TTL is reasonable; user can re-open viewer |
|
||||
| Staging files accumulate | Disk fills up | Medium | Enable `cleanupStaging.enabled: true` |
|
||||
| Missing error boundaries | Component crashes show unhandled error | Low | Error boundaries now implemented |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Update node affinity** to `required` for compute class (or add anti-affinity for Pi 3)
|
||||
2. **Enable Tailscale LoadBalancer service** for MinIO S3 for reliable Range requests
|
||||
3. **Enable cleanup CronJob** after initial testing: `--set cronjobs.cleanupStaging.enabled=true`
|
||||
4. **Deploy to cluster** and run validation commands
|
||||
5. **Perform end-to-end testing** with real media (upload + scan)
|
||||
6. **Monitor resource usage** during typical operations to confirm limits are appropriate
|
||||
@@ -36,12 +36,14 @@ This plan is written to be executed by multiple subagents (parallelizable workst
|
||||
## 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)
|
||||
@@ -51,6 +53,7 @@ This plan is written to be executed by multiple subagents (parallelizable workst
|
||||
- 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**.
|
||||
@@ -60,20 +63,24 @@ This plan is written to be executed by multiple subagents (parallelizable workst
|
||||
- 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).
|
||||
|
||||
@@ -82,6 +89,7 @@ This plan is written to be executed by multiple subagents (parallelizable workst
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
- **Web**: Next.js (UI + API)
|
||||
- **Worker**: Node worker using BullMQ
|
||||
- **Queue**: Redis
|
||||
@@ -89,6 +97,7 @@ This plan is written to be executed by multiple subagents (parallelizable workst
|
||||
- **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.
|
||||
@@ -146,6 +155,7 @@ Example bucket: `media`.
|
||||
- `raw_tags_json` (jsonb, optional but recommended for debugging)
|
||||
|
||||
Indexes:
|
||||
|
||||
- `capture_ts_utc`, `status`, `media_type`
|
||||
|
||||
### Table: `imports`
|
||||
@@ -161,11 +171,13 @@ Indexes:
|
||||
## 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.
|
||||
@@ -177,6 +189,7 @@ Indexes:
|
||||
- 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`.
|
||||
@@ -186,12 +199,14 @@ Indexes:
|
||||
## 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
|
||||
@@ -205,10 +220,12 @@ Indexes:
|
||||
## 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).
|
||||
@@ -219,11 +236,13 @@ Indexes:
|
||||
- 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.
|
||||
|
||||
---
|
||||
@@ -231,6 +250,7 @@ Indexes:
|
||||
## Kubernetes Deployment Plan (Pi-aware)
|
||||
|
||||
### Scheduling
|
||||
|
||||
- Label nodes:
|
||||
- Pi 5 nodes: `node-class=compute`
|
||||
- Pi 3 node: `node-class=tiny`
|
||||
@@ -238,6 +258,7 @@ Indexes:
|
||||
- `web`, `worker`, `minio`, `postgres`, `redis`
|
||||
|
||||
### Workloads
|
||||
|
||||
- `StatefulSet/minio` (single-node) + Longhorn PVC
|
||||
- `StatefulSet/postgres` + Longhorn PVC
|
||||
- `Deployment/redis`
|
||||
@@ -246,6 +267,7 @@ Indexes:
|
||||
- `CronJob/cleanup-staging` (optional; disabled by default)
|
||||
|
||||
### Exposure
|
||||
|
||||
- Tailscale Ingress (HTTPS termination):
|
||||
- `app.<tailnet-fqdn>` → web service
|
||||
- `minio.<tailnet-fqdn>` → MinIO S3 (9000)
|
||||
@@ -253,6 +275,7 @@ Indexes:
|
||||
- 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.
|
||||
|
||||
@@ -261,10 +284,12 @@ Indexes:
|
||||
## 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.
|
||||
@@ -289,19 +314,19 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
||||
- 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) |
|
||||
| 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 | completed | QA checklist created, error boundaries added, UI resilience improved, deployment validation documented, k8s recommendations applied (required affinity, Tailscale LB service, cleanup CronJob enabled) |
|
||||
|
||||
- Entry point: `./.agents/README.md`
|
||||
- Agent briefs:
|
||||
@@ -314,32 +339,35 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
||||
|
||||
### 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` |
|
||||
| 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.
|
||||
@@ -347,6 +375,7 @@ Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/
|
||||
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.
|
||||
@@ -356,6 +385,7 @@ Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/
|
||||
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.
|
||||
@@ -363,18 +393,21 @@ Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilo
|
||||
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.
|
||||
@@ -382,6 +415,7 @@ Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/
|
||||
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.
|
||||
@@ -390,6 +424,7 @@ Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/
|
||||
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.
|
||||
@@ -399,6 +434,7 @@ Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/
|
||||
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.
|
||||
@@ -410,31 +446,38 @@ Owner: `qa-review` (brief: `./.agents/qa-review.md`, model: `github-copilot/clau
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Component, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type State = {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="flex min-h-[200px] items-center justify-center rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold text-red-700">
|
||||
Something went wrong
|
||||
</p>
|
||||
<p className="text-sm text-red-600">
|
||||
Please refresh the page or try again later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ 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));
|
||||
return new Date(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0),
|
||||
);
|
||||
}
|
||||
|
||||
function endOfDayUtc(iso: string) {
|
||||
@@ -47,7 +49,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{
|
||||
posterUrl: string | null;
|
||||
} | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
@@ -75,7 +80,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
limit: "120",
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
|
||||
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;
|
||||
|
||||
@@ -94,7 +101,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
|
||||
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",
|
||||
});
|
||||
@@ -120,21 +130,47 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<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)"}
|
||||
{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>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 rounded-lg border border-gray-200 bg-white p-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-18 w-18 animate-pulse rounded-lg bg-gray-200"
|
||||
style={{ width: 72, height: 72 }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{assets ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
||||
{assets.length === 0 ? (
|
||||
<div style={{ color: "#666" }}>No assets.</div>
|
||||
) : null}
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
@@ -143,8 +179,11 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
onPointerEnter={() => {
|
||||
if (previews[a.id] !== undefined) return;
|
||||
|
||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
||||
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 }));
|
||||
@@ -153,18 +192,28 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: 10,
|
||||
border: "1px solid #ddd",
|
||||
border:
|
||||
a.status === "failed"
|
||||
? "2px solid #ef4444"
|
||||
: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "72px 1fr",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
background: "#f2f2f2",
|
||||
background: a.status === "failed" ? "#fef2f2" : "#f2f2f2",
|
||||
border: "1px solid #eee",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
@@ -173,23 +222,45 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previews[a.id] ? (
|
||||
{a.status === "failed" ? (
|
||||
<span style={{ fontSize: 24, color: "#ef4444" }}>⚠</span>
|
||||
) : 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" }} />
|
||||
<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 }}>
|
||||
<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>
|
||||
<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>
|
||||
<div
|
||||
style={{ color: "#ef4444", fontSize: 12, marginTop: 6 }}
|
||||
>
|
||||
{a.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,76 +287,105 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
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
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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 }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={retryKey}
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
key={retryKey}
|
||||
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 ? (
|
||||
{viewerError ? (
|
||||
<div className="flex items-center justify-between gap-4 rounded border border-red-200 bg-red-50 p-3">
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{viewerError}
|
||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewerError(null);
|
||||
setRetryKey((k) => k + 1);
|
||||
}}
|
||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
{viewer.asset.id}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>
|
||||
{viewerError ?? "unknown_error"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,11 +102,17 @@ function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
||||
}
|
||||
|
||||
// Sort ascending within each parent
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
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));
|
||||
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));
|
||||
m.children = (m.children ?? []).sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +123,8 @@ function gatherVisible(
|
||||
roots: TreeNode[],
|
||||
expanded: ExpandedState,
|
||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
||||
const out: 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) {
|
||||
@@ -133,7 +140,9 @@ function gatherVisible(
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
|
||||
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);
|
||||
@@ -154,9 +163,12 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
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);
|
||||
@@ -171,7 +183,10 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
}, []);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
|
||||
const visible = useMemo(
|
||||
() => gatherVisible(roots, expanded),
|
||||
[roots, expanded],
|
||||
);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const nodeGap = 56;
|
||||
@@ -207,7 +222,11 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
|
||||
useEffect(() => {
|
||||
// reset viewBox when layout changes
|
||||
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
|
||||
setViewBox((vb) => ({
|
||||
...vb,
|
||||
w: Math.max(layout.w, 1200),
|
||||
h: Math.max(layout.h, 800),
|
||||
}));
|
||||
}, [layout.w, layout.h]);
|
||||
|
||||
function toggleNode(id: string) {
|
||||
@@ -271,22 +290,55 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<strong>Timeline</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
||||
onClick={() =>
|
||||
setOrientation((o) =>
|
||||
o === "vertical" ? "horizontal" : "vertical",
|
||||
)
|
||||
}
|
||||
>
|
||||
Orientation: {orientation}
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
|
||||
<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}
|
||||
{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}
|
||||
{!rows && !error ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
height: 600,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Application error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="max-w-md rounded-lg border border-red-200 bg-red-50 p-8 text-center">
|
||||
<h1 className="mb-4 text-xl font-semibold text-red-700">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mb-6 text-sm text-red-600">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded bg-red-600 px-4 py-2 text-white transition hover:bg-red-700"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { getAppName } from "@tline/config";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { MediaPanel } from "./components/MediaPanel";
|
||||
import { TimelineTree } from "./components/TimelineTree";
|
||||
|
||||
@@ -32,8 +33,12 @@ export default function HomePage() {
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<TimelineTree onSelectDay={setSelectedDayIso} />
|
||||
<MediaPanel selectedDayIso={selectedDayIso} />
|
||||
<ErrorBoundary>
|
||||
<TimelineTree onSelectDay={setSelectedDayIso} />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<MediaPanel selectedDayIso={selectedDayIso} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -140,6 +140,19 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.registryServer" -}}
|
||||
{{- if .Values.registrySecret.server -}}
|
||||
{{- .Values.registrySecret.server -}}
|
||||
{{- else -}}
|
||||
{{- /* Derive registry host from image repository (first path segment). */ -}}
|
||||
{{- $repo := .Values.images.worker.repository | default .Values.images.web.repository | default "" -}}
|
||||
{{- if not $repo -}}
|
||||
{{- fail "registrySecret.server is required when no images.*.repository is set" -}}
|
||||
{{- end -}}
|
||||
{{- (first (regexSplit "/" $repo -1)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "tline.imagePullSecrets" -}}
|
||||
{{- $secrets := .Values.imagePullSecrets | default (list) -}}
|
||||
{{- if .Values.registrySecret.create -}}
|
||||
|
||||
@@ -29,8 +29,8 @@ metadata:
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
{{ $server := required "registrySecret.server is required" .Values.registrySecret.server -}}
|
||||
{{ $user := .Values.registrySecret.username | default "" -}}
|
||||
{{ $server := include "tline.registryServer" . -}}
|
||||
{{ $user := required "registrySecret.username is required" .Values.registrySecret.username -}}
|
||||
{{ $pass := required "registrySecret.password is required" .Values.registrySecret.password -}}
|
||||
{{ $email := .Values.registrySecret.email | default "" -}}
|
||||
{{ $auth := printf "%s:%s" $user $pass | b64enc -}}
|
||||
|
||||
+10
-10
@@ -17,11 +17,10 @@ scheduling:
|
||||
compute:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
# Prefer compute nodes when they exist, but don't require them.
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
# Require compute nodes (Pi 5). Pi 3 has capacity=low:NoExecute taint.
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class
|
||||
operator: In
|
||||
values:
|
||||
@@ -88,9 +87,10 @@ imagePullSecrets: []
|
||||
registrySecret:
|
||||
create: false
|
||||
name: "" # defaults to <release>-<chart>-registry
|
||||
server: "" # e.g. registry.lan:5000
|
||||
username: ""
|
||||
password: ""
|
||||
# Registry host. If empty, derived from images.*.repository (first path segment).
|
||||
server: "" # e.g. gitea-gitea-http.taildb3494.ts.net
|
||||
username: "" # required when create=true
|
||||
password: "" # required when create=true
|
||||
email: ""
|
||||
|
||||
web:
|
||||
@@ -169,7 +169,7 @@ minio:
|
||||
# This can be more reliable for streaming / Range requests depending on
|
||||
# Tailscale operator + cluster behavior.
|
||||
tailscaleServiceS3:
|
||||
enabled: false
|
||||
enabled: true
|
||||
hostnameLabel: minio
|
||||
tags: []
|
||||
extraAnnotations: {}
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
|
||||
cronjobs:
|
||||
cleanupStaging:
|
||||
enabled: false
|
||||
enabled: true
|
||||
schedule: "0 4 * * *"
|
||||
# Remove objects under `staging/` older than this many days.
|
||||
# This CronJob must never touch `originals/`.
|
||||
|
||||
Reference in New Issue
Block a user