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)
|
## Key Decisions (Locked)
|
||||||
|
|
||||||
### App identity
|
### App identity
|
||||||
|
|
||||||
- App name: `porthole`
|
- App name: `porthole`
|
||||||
- Set the app name via environment variable: `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.
|
- 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`).
|
- 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
|
### Networking
|
||||||
|
|
||||||
- Tailnet clients access the app via **Tailscale Ingress HTTPS termination**.
|
- Tailnet clients access the app via **Tailscale Ingress HTTPS termination**.
|
||||||
- MinIO is reachable **over tailnet** via a dedicated FQDN:
|
- MinIO is reachable **over tailnet** via a dedicated FQDN:
|
||||||
- `https://minio.<tailnet-fqdn>` (S3 API)
|
- `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.
|
- Optional LAN ingress exists using `nip.io` and nginx ingress, but tailnet clients use Tailscale hostnames.
|
||||||
|
|
||||||
### Storage model
|
### Storage model
|
||||||
|
|
||||||
- **MinIO is the source of truth**.
|
- **MinIO is the source of truth**.
|
||||||
- External archive objects under **`originals/`** are treated as **immutable**:
|
- External archive objects under **`originals/`** are treated as **immutable**:
|
||||||
- The app **indexes in place**.
|
- 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.
|
- Uploads are processed then stored in canonical by default.
|
||||||
|
|
||||||
### Presigned URL strategy
|
### Presigned URL strategy
|
||||||
|
|
||||||
- Use **path-style presigned URLs** signed against:
|
- Use **path-style presigned URLs** signed against:
|
||||||
- `MINIO_PUBLIC_ENDPOINT_TS=https://minio.<tailnet-fqdn>`
|
- `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.
|
- Using HTTPS for MinIO on tailnet avoids mixed-content block when the app is served via HTTPS.
|
||||||
|
|
||||||
### Kubernetes constraints
|
### Kubernetes constraints
|
||||||
|
|
||||||
- Cluster nodes: **2× Raspberry Pi 5 (8GB)** + **1× Raspberry Pi 3 B+ (1GB)**.
|
- Cluster nodes: **2× Raspberry Pi 5 (8GB)** + **1× Raspberry Pi 3 B+ (1GB)**.
|
||||||
- Heavy pods must be pinned to Pi 5 nodes.
|
- 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**.
|
- Multi-arch images required (arm64 + amd64), built on a laptop and pushed to an in-cluster **insecure HTTP registry**.
|
||||||
|
|
||||||
### Metadata extraction
|
### Metadata extraction
|
||||||
|
|
||||||
- **Photos**: camera-like EXIF first (`DateTimeOriginal`), then fallbacks.
|
- **Photos**: camera-like EXIF first (`DateTimeOriginal`), then fallbacks.
|
||||||
- **Videos**: camera-like tags first (ExifTool QuickTime/vendor tags), fallback to universal container `creation_time`.
|
- **Videos**: camera-like tags first (ExifTool QuickTime/vendor tags), fallback to universal container `creation_time`.
|
||||||
|
|
||||||
### Derived media
|
### Derived media
|
||||||
|
|
||||||
- Image thumbs: `image_256.jpg` and `image_768.jpg`.
|
- Image thumbs: `image_256.jpg` and `image_768.jpg`.
|
||||||
- Video posters: only `poster_256.jpg` initially (CPU-friendly).
|
- 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
|
## Architecture
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
- **Web**: Next.js (UI + API)
|
- **Web**: Next.js (UI + API)
|
||||||
- **Worker**: Node worker using BullMQ
|
- **Worker**: Node worker using BullMQ
|
||||||
- **Queue**: Redis
|
- **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)
|
- **Object store**: MinIO (in-cluster, single-node)
|
||||||
|
|
||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
1. Ingestion (upload or scan) creates/updates DB asset records.
|
1. Ingestion (upload or scan) creates/updates DB asset records.
|
||||||
2. Worker extracts metadata and generates thumbs/posters.
|
2. Worker extracts metadata and generates thumbs/posters.
|
||||||
3. UI queries aggregated timeline nodes and displays a tree.
|
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)
|
- `raw_tags_json` (jsonb, optional but recommended for debugging)
|
||||||
|
|
||||||
Indexes:
|
Indexes:
|
||||||
|
|
||||||
- `capture_ts_utc`, `status`, `media_type`
|
- `capture_ts_utc`, `status`, `media_type`
|
||||||
|
|
||||||
### Table: `imports`
|
### Table: `imports`
|
||||||
@@ -161,11 +171,13 @@ Indexes:
|
|||||||
## Worker Jobs (BullMQ)
|
## Worker Jobs (BullMQ)
|
||||||
|
|
||||||
### `scan_minio_prefix(importId, bucket, prefix)`
|
### `scan_minio_prefix(importId, bucket, prefix)`
|
||||||
|
|
||||||
- Guardrails: only allow prefixes from allowlist, starting with `originals/`.
|
- Guardrails: only allow prefixes from allowlist, starting with `originals/`.
|
||||||
- Lists objects; upserts `assets` by `source_key`.
|
- Lists objects; upserts `assets` by `source_key`.
|
||||||
- Enqueues `process_asset(assetId)`.
|
- Enqueues `process_asset(assetId)`.
|
||||||
|
|
||||||
### `process_asset(assetId)`
|
### `process_asset(assetId)`
|
||||||
|
|
||||||
- Downloads object (stream or temp file).
|
- Downloads object (stream or temp file).
|
||||||
- Extracts metadata:
|
- Extracts metadata:
|
||||||
- Photos: ExifTool EXIF chain.
|
- 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.
|
- Never throws errors that would crash the worker loop; failures are captured on the asset row.
|
||||||
|
|
||||||
### `copy_to_canonical(assetId)`
|
### `copy_to_canonical(assetId)`
|
||||||
|
|
||||||
- Computes canonical key: `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`.
|
- Computes canonical key: `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`.
|
||||||
- Copy-only; never deletes `source_key` for external archive.
|
- Copy-only; never deletes `source_key` for external archive.
|
||||||
- Updates `canonical_key` and flips `active_key`.
|
- Updates `canonical_key` and flips `active_key`.
|
||||||
@@ -186,12 +199,14 @@ Indexes:
|
|||||||
## API (MVP)
|
## API (MVP)
|
||||||
|
|
||||||
### Admin ingestion
|
### Admin ingestion
|
||||||
|
|
||||||
- `POST /api/imports` → create import batch
|
- `POST /api/imports` → create import batch
|
||||||
- `POST /api/imports/:id/upload` → upload media to `staging/` and enqueue processing
|
- `POST /api/imports/:id/upload` → upload media to `staging/` and enqueue processing
|
||||||
- `POST /api/imports/:id/scan-minio` → enqueue scan of allowlisted prefix
|
- `POST /api/imports/:id/scan-minio` → enqueue scan of allowlisted prefix
|
||||||
- `GET /api/imports/:id/status` → progress
|
- `GET /api/imports/:id/status` → progress
|
||||||
|
|
||||||
### Timeline and browsing
|
### Timeline and browsing
|
||||||
|
|
||||||
- `GET /api/tree`
|
- `GET /api/tree`
|
||||||
- params: `start`, `end`, `granularity=year|month|day`, filters: `mediaType`
|
- params: `start`, `end`, `granularity=year|month|day`, filters: `mediaType`
|
||||||
- returns nodes with counts and sample thumbs
|
- returns nodes with counts and sample thumbs
|
||||||
@@ -205,10 +220,12 @@ Indexes:
|
|||||||
## Frontend UX/UI (MVP)
|
## Frontend UX/UI (MVP)
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
|
|
||||||
- `/` Timeline tree
|
- `/` Timeline tree
|
||||||
- `/admin` Admin tools (upload, scan, import status)
|
- `/admin` Admin tools (upload, scan, import status)
|
||||||
|
|
||||||
### Timeline tree
|
### Timeline tree
|
||||||
|
|
||||||
- SVG tree rendering with:
|
- SVG tree rendering with:
|
||||||
- Vertical/horizontal orientation toggle.
|
- Vertical/horizontal orientation toggle.
|
||||||
- Zoom/pan (touch supported).
|
- Zoom/pan (touch supported).
|
||||||
@@ -219,11 +236,13 @@ Indexes:
|
|||||||
- Virtualized thumbnail list.
|
- Virtualized thumbnail list.
|
||||||
|
|
||||||
### Viewer
|
### Viewer
|
||||||
|
|
||||||
- Image viewer modal.
|
- Image viewer modal.
|
||||||
- Video playback via HTML5 `<video>` on the presigned URL.
|
- Video playback via HTML5 `<video>` on the presigned URL.
|
||||||
- If a video can’t be played (codec/container): show poster + message.
|
- If a video can’t be played (codec/container): show poster + message.
|
||||||
|
|
||||||
### Resilience
|
### Resilience
|
||||||
|
|
||||||
- Any media with `status=failed` renders as a placeholder tile and does not break aggregation or layout.
|
- 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)
|
## Kubernetes Deployment Plan (Pi-aware)
|
||||||
|
|
||||||
### Scheduling
|
### Scheduling
|
||||||
|
|
||||||
- Label nodes:
|
- Label nodes:
|
||||||
- Pi 5 nodes: `node-class=compute`
|
- Pi 5 nodes: `node-class=compute`
|
||||||
- Pi 3 node: `node-class=tiny`
|
- Pi 3 node: `node-class=tiny`
|
||||||
@@ -238,6 +258,7 @@ Indexes:
|
|||||||
- `web`, `worker`, `minio`, `postgres`, `redis`
|
- `web`, `worker`, `minio`, `postgres`, `redis`
|
||||||
|
|
||||||
### Workloads
|
### Workloads
|
||||||
|
|
||||||
- `StatefulSet/minio` (single-node) + Longhorn PVC
|
- `StatefulSet/minio` (single-node) + Longhorn PVC
|
||||||
- `StatefulSet/postgres` + Longhorn PVC
|
- `StatefulSet/postgres` + Longhorn PVC
|
||||||
- `Deployment/redis`
|
- `Deployment/redis`
|
||||||
@@ -246,6 +267,7 @@ Indexes:
|
|||||||
- `CronJob/cleanup-staging` (optional; disabled by default)
|
- `CronJob/cleanup-staging` (optional; disabled by default)
|
||||||
|
|
||||||
### Exposure
|
### Exposure
|
||||||
|
|
||||||
- Tailscale Ingress (HTTPS termination):
|
- Tailscale Ingress (HTTPS termination):
|
||||||
- `app.<tailnet-fqdn>` → web service
|
- `app.<tailnet-fqdn>` → web service
|
||||||
- `minio.<tailnet-fqdn>` → MinIO S3 (9000)
|
- `minio.<tailnet-fqdn>` → MinIO S3 (9000)
|
||||||
@@ -253,6 +275,7 @@ Indexes:
|
|||||||
- Optional LAN nginx ingress + MetalLB for `nip.io` hostnames.
|
- Optional LAN nginx ingress + MetalLB for `nip.io` hostnames.
|
||||||
|
|
||||||
### Ingress notes
|
### Ingress notes
|
||||||
|
|
||||||
- For uploads and media streaming, configure timeouts and body size to support “large but not gigantic” media.
|
- For uploads and media streaming, configure timeouts and body size to support “large but not gigantic” media.
|
||||||
- Ensure Range requests work for video playback.
|
- Ensure Range requests work for video playback.
|
||||||
|
|
||||||
@@ -261,10 +284,12 @@ Indexes:
|
|||||||
## Build & Release (Multi-arch)
|
## Build & Release (Multi-arch)
|
||||||
|
|
||||||
### Package manager
|
### Package manager
|
||||||
|
|
||||||
- Use **Bun** for installs and scripts (`bun install`, `bun run ...`).
|
- Use **Bun** for installs and scripts (`bun install`, `bun run ...`).
|
||||||
- Avoid `npm`/`pnpm` in CI and docs unless required for a specific tool.
|
- Avoid `npm`/`pnpm` in CI and docs unless required for a specific tool.
|
||||||
|
|
||||||
### Container build
|
### Container build
|
||||||
|
|
||||||
- Build on laptop using Docker Buildx.
|
- Build on laptop using Docker Buildx.
|
||||||
- Push `linux/arm64` and `linux/amd64` images to local in-cluster registry over **insecure HTTP**.
|
- 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.
|
- Use Debian-slim Node base images for better ARM64 compatibility with `sharp` + ffmpeg.
|
||||||
@@ -290,7 +315,7 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
|||||||
- Exactly one task should be marked `in_progress` at a time.
|
- Exactly one task should be marked `in_progress` at a time.
|
||||||
|
|
||||||
| Task | Status | Notes |
|
| Task | Status | Notes |
|
||||||
|---|---|---|
|
| ------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 1 — Repository scaffolding | completed | Bun workspace + shared config scaffold |
|
| 1 — Repository scaffolding | completed | Bun workspace + shared config scaffold |
|
||||||
| 2 — Database schema + migrations | completed | assets/imports schema + migration runner |
|
| 2 — Database schema + migrations | completed | assets/imports schema + migration runner |
|
||||||
| 3 — MinIO client + presigned URL strategy | completed | @tline/minio + presigned URL API route |
|
| 3 — MinIO client + presigned URL strategy | completed | @tline/minio + presigned URL API route |
|
||||||
@@ -301,7 +326,7 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
|||||||
| 8 — Timeline tree frontend | completed | basic SVG tree + orientation toggle |
|
| 8 — Timeline tree frontend | completed | basic SVG tree + orientation toggle |
|
||||||
| 9 — Media panel + viewer | completed | day selection, asset list, preview + viewer |
|
| 9 — Media panel + viewer | completed | day selection, asset list, preview + viewer |
|
||||||
| 10 — k8s deployment (Pi-aware) | completed | Helm chart + Tailscale ingress |
|
| 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) |
|
| 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`
|
- Entry point: `./.agents/README.md`
|
||||||
- Agent briefs:
|
- Agent briefs:
|
||||||
@@ -315,7 +340,7 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
|||||||
### Subagents and assigned model
|
### Subagents and assigned model
|
||||||
|
|
||||||
| Subagent | Responsibility | LLM Model |
|
| Subagent | Responsibility | LLM Model |
|
||||||
|---|---|---|
|
| -------------- | -------------------------------------------------------------------------------- | ---------------------------------- |
|
||||||
| `orchestrator` | backlog coordination, interfaces, acceptance criteria | `github-copilot/gpt-5.2` |
|
| `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` |
|
| `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` |
|
| `worker-media` | BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters | `github-copilot/claude-sonnet-4.5` |
|
||||||
@@ -328,18 +353,21 @@ This plan is intended to be executed in parallel by multiple subagents. Each sub
|
|||||||
### Task breakdown (MVP)
|
### Task breakdown (MVP)
|
||||||
|
|
||||||
#### Task 1 — Repository scaffolding
|
#### Task 1 — Repository scaffolding
|
||||||
|
|
||||||
- Define folder structure (apps/web, apps/worker, helm/).
|
- Define folder structure (apps/web, apps/worker, helm/).
|
||||||
- Add shared `config` module (env validation).
|
- Add shared `config` module (env validation).
|
||||||
|
|
||||||
Owner: `orchestrator` (brief: `./.agents/orchestrator.md`, model: `github-copilot/gpt-5.2`)
|
Owner: `orchestrator` (brief: `./.agents/orchestrator.md`, model: `github-copilot/gpt-5.2`)
|
||||||
|
|
||||||
#### Task 2 — Database schema + migrations
|
#### Task 2 — Database schema + migrations
|
||||||
|
|
||||||
- Implement `assets`/`imports` schema.
|
- Implement `assets`/`imports` schema.
|
||||||
- Add indexes.
|
- Add indexes.
|
||||||
|
|
||||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 3 — MinIO client + presigned URL strategy
|
#### Task 3 — MinIO client + presigned URL strategy
|
||||||
|
|
||||||
- Implement internal client for cluster operations.
|
- Implement internal client for cluster operations.
|
||||||
- Implement public-signing client for tailnet endpoint.
|
- Implement public-signing client for tailnet endpoint.
|
||||||
- Enforce path-style URLs.
|
- 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`)
|
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 4 — Worker pipeline (process images/videos)
|
#### Task 4 — Worker pipeline (process images/videos)
|
||||||
|
|
||||||
- ExifTool extraction (photos + camera-like video fields).
|
- ExifTool extraction (photos + camera-like video fields).
|
||||||
- ffprobe technical metadata; fallback `creation_time`.
|
- ffprobe technical metadata; fallback `creation_time`.
|
||||||
- `sharp` thumbs for images.
|
- `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`)
|
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 5 — Ingestion endpoints (upload + scan)
|
#### Task 5 — Ingestion endpoints (upload + scan)
|
||||||
|
|
||||||
- Admin upload endpoint: stream to `staging/`.
|
- Admin upload endpoint: stream to `staging/`.
|
||||||
- Scan endpoint: enqueue `scan_minio_prefix` only for allowlisted prefix `originals/`.
|
- Scan endpoint: enqueue `scan_minio_prefix` only for allowlisted prefix `originals/`.
|
||||||
- Import status endpoint.
|
- 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`)
|
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 6 — Canonical copy logic (uploads default)
|
#### Task 6 — Canonical copy logic (uploads default)
|
||||||
|
|
||||||
- For uploads, copy to canonical date key, flip `active_key`.
|
- For uploads, copy to canonical date key, flip `active_key`.
|
||||||
- For scans, optional manual/cron copy.
|
- For scans, optional manual/cron copy.
|
||||||
|
|
||||||
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
|
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 7 — Timeline aggregation API
|
#### Task 7 — Timeline aggregation API
|
||||||
|
|
||||||
- `GET /api/tree` for year/month/day rolling up counts.
|
- `GET /api/tree` for year/month/day rolling up counts.
|
||||||
- Select sample thumbs per node.
|
- Select sample thumbs per node.
|
||||||
|
|
||||||
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 8 — Timeline tree frontend
|
#### Task 8 — Timeline tree frontend
|
||||||
|
|
||||||
- Interactive tree with orientation toggle.
|
- Interactive tree with orientation toggle.
|
||||||
- Touch zoom/pan.
|
- Touch zoom/pan.
|
||||||
- Expand/collapse.
|
- 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`)
|
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
|
||||||
|
|
||||||
#### Task 9 — Media panel + viewer
|
#### Task 9 — Media panel + viewer
|
||||||
|
|
||||||
- Virtualized thumbnail list.
|
- Virtualized thumbnail list.
|
||||||
- Viewer modal for images.
|
- Viewer modal for images.
|
||||||
- Video playback with poster fallback.
|
- 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`)
|
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
|
||||||
|
|
||||||
#### Task 10 — k8s deployment (Pi-aware)
|
#### Task 10 — k8s deployment (Pi-aware)
|
||||||
|
|
||||||
- Helm chart or Kustomize.
|
- Helm chart or Kustomize.
|
||||||
- Node affinity to Pi 5 nodes.
|
- Node affinity to Pi 5 nodes.
|
||||||
- Longhorn PVCs.
|
- 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`)
|
Owner: `k8s-infra` (brief: `./.agents/k8s-infra.md`, model: `github-copilot/claude-sonnet-4.5`)
|
||||||
|
|
||||||
#### Task 11 — QA + hardening
|
#### Task 11 — QA + hardening
|
||||||
|
|
||||||
- Edge case tests: missing EXIF, odd timezones, unsupported video codecs.
|
- Edge case tests: missing EXIF, odd timezones, unsupported video codecs.
|
||||||
- Validate Range playback through ingress.
|
- Validate Range playback through ingress.
|
||||||
- Verify no UI crash on failed assets.
|
- 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)
|
## Future Features (Tracked)
|
||||||
|
|
||||||
### Security / Access
|
### Security / Access
|
||||||
|
|
||||||
- Authentication and authorization.
|
- Authentication and authorization.
|
||||||
- Lightweight admin protection (shared secret header) before full auth.
|
- Lightweight admin protection (shared secret header) before full auth.
|
||||||
|
|
||||||
### Media
|
### Media
|
||||||
|
|
||||||
- Video transcoding CronJob (H.264 MP4 and/or HLS) and “prefer derived” playback.
|
- Video transcoding CronJob (H.264 MP4 and/or HLS) and “prefer derived” playback.
|
||||||
- Multiple poster/thumb sizes.
|
- Multiple poster/thumb sizes.
|
||||||
- Better codec support via transcode profiles.
|
- Better codec support via transcode profiles.
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
|
|
||||||
- User-defined albums and tags.
|
- User-defined albums and tags.
|
||||||
- Progressive enhancement for folder upload where supported.
|
- Progressive enhancement for folder upload where supported.
|
||||||
- Bucket separation (`media` vs `derived`) or lifecycle policies.
|
- Bucket separation (`media` vs `derived`) or lifecycle policies.
|
||||||
|
|
||||||
### Metadata
|
### Metadata
|
||||||
|
|
||||||
- Location: GPS extraction + reverse geocoding + map UI.
|
- Location: GPS extraction + reverse geocoding + map UI.
|
||||||
- Metadata edits/overrides (fix dates, correct capture time), audit log.
|
- Metadata edits/overrides (fix dates, correct capture time), audit log.
|
||||||
|
|
||||||
### Performance / Scale
|
### Performance / Scale
|
||||||
|
|
||||||
- Deduplication by hash.
|
- Deduplication by hash.
|
||||||
- Smarter clustering (“moments”) within a day.
|
- Smarter clustering (“moments”) within a day.
|
||||||
|
|
||||||
### Networking
|
### Networking
|
||||||
|
|
||||||
- Routed LAN for tailnet clients (subnet router) and endpoint selection for presigned URLs.
|
- Routed LAN for tailnet clients (subnet router) and endpoint selection for presigned URLs.
|
||||||
|
|
||||||
### Delivery
|
### Delivery
|
||||||
|
|
||||||
- Move multi-arch builds from laptop to CI.
|
- 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) {
|
function startOfDayUtc(iso: string) {
|
||||||
const d = new Date(iso);
|
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) {
|
function endOfDayUtc(iso: string) {
|
||||||
@@ -47,7 +49,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [viewerError, setViewerError] = useState<string | 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(() => {
|
const range = useMemo(() => {
|
||||||
if (!props.selectedDayIso) return null;
|
if (!props.selectedDayIso) return null;
|
||||||
@@ -75,7 +80,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
limit: "120",
|
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}`);
|
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
|
||||||
const json = (await res.json()) as AssetsResponse;
|
const json = (await res.json()) as AssetsResponse;
|
||||||
|
|
||||||
@@ -94,7 +101,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
};
|
};
|
||||||
}, [range]);
|
}, [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}`, {
|
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -120,21 +130,47 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gap: 12 }}>
|
<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>
|
<strong>Media</strong>
|
||||||
<span style={{ color: "#666", fontSize: 12 }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||||
{!assets && props.selectedDayIso && !error ? (
|
{!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}
|
) : null}
|
||||||
|
|
||||||
{assets ? (
|
{assets ? (
|
||||||
<div style={{ display: "grid", gap: 8 }}>
|
<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) => (
|
{assets.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -143,8 +179,11 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
onPointerEnter={() => {
|
onPointerEnter={() => {
|
||||||
if (previews[a.id] !== undefined) return;
|
if (previews[a.id] !== undefined) return;
|
||||||
|
|
||||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
const variant =
|
||||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
a.media_type === "image" ? "thumb_small" : "poster";
|
||||||
|
const promise = loadSignedUrl(a.id, variant).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
void promise.then((url) => {
|
void promise.then((url) => {
|
||||||
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
||||||
@@ -153,18 +192,28 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
padding: 10,
|
padding: 10,
|
||||||
border: "1px solid #ddd",
|
border:
|
||||||
|
a.status === "failed"
|
||||||
|
? "2px solid #ef4444"
|
||||||
|
: "1px solid #ddd",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: "white",
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: "#f2f2f2",
|
background: a.status === "failed" ? "#fef2f2" : "#f2f2f2",
|
||||||
border: "1px solid #eee",
|
border: "1px solid #eee",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -173,23 +222,45 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
fontSize: 12,
|
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
|
// 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>
|
<span>{a.media_type}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>
|
<span>
|
||||||
{a.media_type} · {a.status}
|
{a.media_type} · {a.status}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{a.status === "failed" && a.error_message ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,8 +300,18 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
<div
|
||||||
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{viewer
|
||||||
|
? `${viewer.asset.media_type} (${viewer.variant})`
|
||||||
|
: "Viewer"}
|
||||||
|
</strong>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -248,6 +329,7 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
{viewer.asset.media_type === "image" ? (
|
{viewer.asset.media_type === "image" ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
|
key={retryKey}
|
||||||
src={viewer.url}
|
src={viewer.url}
|
||||||
alt={viewer.asset.id}
|
alt={viewer.asset.id}
|
||||||
style={{ width: "100%", height: "auto" }}
|
style={{ width: "100%", height: "auto" }}
|
||||||
@@ -255,6 +337,7 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<video
|
<video
|
||||||
|
key={retryKey}
|
||||||
src={viewer.url}
|
src={viewer.url}
|
||||||
controls
|
controls
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
@@ -271,16 +354,34 @@ export function MediaPanel(props: { selectedDayIso: string | 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 }}>
|
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||||
{viewerError}
|
{viewerError}
|
||||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
{viewer.asset.media_type === "video"
|
||||||
|
? " (try a different browser/codec)"
|
||||||
|
: 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
<div style={{ color: "#666", fontSize: 12 }}>
|
||||||
|
{viewer.asset.id}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
<div style={{ color: "#b00" }}>
|
||||||
|
{viewerError ?? "unknown_error"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,4 +389,3 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,11 +102,17 @@ function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort ascending within each parent
|
// 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) {
|
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) {
|
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[],
|
roots: TreeNode[],
|
||||||
expanded: ExpandedState,
|
expanded: ExpandedState,
|
||||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
): 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) {
|
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
@@ -133,7 +140,9 @@ function gatherVisible(
|
|||||||
return out;
|
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 [orientation, setOrientation] = useState<Orientation>("vertical");
|
||||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||||
@@ -154,9 +163,12 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
const res = await fetch(
|
||||||
|
"/api/tree?granularity=day&limit=500&includeFailed=1",
|
||||||
|
{
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
||||||
const json = (await res.json()) as ApiTreeResponse;
|
const json = (await res.json()) as ApiTreeResponse;
|
||||||
if (!cancelled) setRows(json.nodes);
|
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 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 layout = useMemo(() => {
|
||||||
const nodeGap = 56;
|
const nodeGap = 56;
|
||||||
@@ -207,7 +222,11 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// reset viewBox when layout changes
|
// 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]);
|
}, [layout.w, layout.h]);
|
||||||
|
|
||||||
function toggleNode(id: string) {
|
function toggleNode(id: string) {
|
||||||
@@ -271,22 +290,55 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gap: 12 }}>
|
<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>
|
<strong>Timeline</strong>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
onClick={() =>
|
||||||
|
setOrientation((o) =>
|
||||||
|
o === "vertical" ? "horizontal" : "vertical",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Orientation: {orientation}
|
Orientation: {orientation}
|
||||||
</button>
|
</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
|
Reset view
|
||||||
</button>
|
</button>
|
||||||
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
|
{rows ? (
|
||||||
|
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
{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
|
<div
|
||||||
style={{
|
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 { getAppName } from "@tline/config";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { MediaPanel } from "./components/MediaPanel";
|
import { MediaPanel } from "./components/MediaPanel";
|
||||||
import { TimelineTree } from "./components/TimelineTree";
|
import { TimelineTree } from "./components/TimelineTree";
|
||||||
|
|
||||||
@@ -32,8 +33,12 @@ export default function HomePage() {
|
|||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
<TimelineTree onSelectDay={setSelectedDayIso} />
|
<TimelineTree onSelectDay={setSelectedDayIso} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
<ErrorBoundary>
|
||||||
<MediaPanel selectedDayIso={selectedDayIso} />
|
<MediaPanel selectedDayIso={selectedDayIso} />
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -140,6 +140,19 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- 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" -}}
|
{{- define "tline.imagePullSecrets" -}}
|
||||||
{{- $secrets := .Values.imagePullSecrets | default (list) -}}
|
{{- $secrets := .Values.imagePullSecrets | default (list) -}}
|
||||||
{{- if .Values.registrySecret.create -}}
|
{{- if .Values.registrySecret.create -}}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
{{ include "tline.labels" . | indent 4 }}
|
{{ include "tline.labels" . | indent 4 }}
|
||||||
type: kubernetes.io/dockerconfigjson
|
type: kubernetes.io/dockerconfigjson
|
||||||
{{ $server := required "registrySecret.server is required" .Values.registrySecret.server -}}
|
{{ $server := include "tline.registryServer" . -}}
|
||||||
{{ $user := .Values.registrySecret.username | default "" -}}
|
{{ $user := required "registrySecret.username is required" .Values.registrySecret.username -}}
|
||||||
{{ $pass := required "registrySecret.password is required" .Values.registrySecret.password -}}
|
{{ $pass := required "registrySecret.password is required" .Values.registrySecret.password -}}
|
||||||
{{ $email := .Values.registrySecret.email | default "" -}}
|
{{ $email := .Values.registrySecret.email | default "" -}}
|
||||||
{{ $auth := printf "%s:%s" $user $pass | b64enc -}}
|
{{ $auth := printf "%s:%s" $user $pass | b64enc -}}
|
||||||
|
|||||||
+10
-10
@@ -17,11 +17,10 @@ scheduling:
|
|||||||
compute:
|
compute:
|
||||||
affinity:
|
affinity:
|
||||||
nodeAffinity:
|
nodeAffinity:
|
||||||
# Prefer compute nodes when they exist, but don't require them.
|
# Require compute nodes (Pi 5). Pi 3 has capacity=low:NoExecute taint.
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
- weight: 100
|
nodeSelectorTerms:
|
||||||
preference:
|
- matchExpressions:
|
||||||
matchExpressions:
|
|
||||||
- key: node-class
|
- key: node-class
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
@@ -88,9 +87,10 @@ imagePullSecrets: []
|
|||||||
registrySecret:
|
registrySecret:
|
||||||
create: false
|
create: false
|
||||||
name: "" # defaults to <release>-<chart>-registry
|
name: "" # defaults to <release>-<chart>-registry
|
||||||
server: "" # e.g. registry.lan:5000
|
# Registry host. If empty, derived from images.*.repository (first path segment).
|
||||||
username: ""
|
server: "" # e.g. gitea-gitea-http.taildb3494.ts.net
|
||||||
password: ""
|
username: "" # required when create=true
|
||||||
|
password: "" # required when create=true
|
||||||
email: ""
|
email: ""
|
||||||
|
|
||||||
web:
|
web:
|
||||||
@@ -169,7 +169,7 @@ minio:
|
|||||||
# This can be more reliable for streaming / Range requests depending on
|
# This can be more reliable for streaming / Range requests depending on
|
||||||
# Tailscale operator + cluster behavior.
|
# Tailscale operator + cluster behavior.
|
||||||
tailscaleServiceS3:
|
tailscaleServiceS3:
|
||||||
enabled: false
|
enabled: true
|
||||||
hostnameLabel: minio
|
hostnameLabel: minio
|
||||||
tags: []
|
tags: []
|
||||||
extraAnnotations: {}
|
extraAnnotations: {}
|
||||||
@@ -240,7 +240,7 @@ jobs:
|
|||||||
|
|
||||||
cronjobs:
|
cronjobs:
|
||||||
cleanupStaging:
|
cleanupStaging:
|
||||||
enabled: false
|
enabled: true
|
||||||
schedule: "0 4 * * *"
|
schedule: "0 4 * * *"
|
||||||
# Remove objects under `staging/` older than this many days.
|
# Remove objects under `staging/` older than this many days.
|
||||||
# This CronJob must never touch `originals/`.
|
# This CronJob must never touch `originals/`.
|
||||||
|
|||||||
Reference in New Issue
Block a user