Compare commits
61 Commits
7e1746d1fb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69f69956f3 | |||
| c44684a63a | |||
| ef7ce32eb3 | |||
| 9d49993398 | |||
| c5f5905209 | |||
| 9dadb3d808 | |||
| d0788f0a52 | |||
| 9b72e33872 | |||
| 35e3cbf52f | |||
| d93caedb31 | |||
| 523460f639 | |||
| fdd1c932fd | |||
| 13aecf5fe2 | |||
| 83f3ff1f69 | |||
| 1952fbaf30 | |||
| a133afad06 | |||
| c6b4095a39 | |||
| 8f59d3ba72 | |||
| 4b2a4808b6 | |||
| 5d2054637f | |||
| 4180e7866c | |||
| d4a3bb3c42 | |||
| ffba6fb290 | |||
| 8eae0c7c97 | |||
| 6030581429 | |||
| d0ad1caec5 | |||
| 6525a553ae | |||
| 1f8c28e1db | |||
| eb712ac9e9 | |||
| e455425d2e | |||
| 51aba941d6 | |||
| 6a38f3b4ea | |||
| b6d588843d | |||
| 691f5908d3 | |||
| 4cd6dfef40 | |||
| 8479f50daa | |||
| 5058afc980 | |||
| 4fecfd469f | |||
| 0bf2f2d827 | |||
| d6e6f275b7 | |||
| 517e21d0b7 | |||
| 26e2d74d2b | |||
| 24a092544e | |||
| 7c8406c7cc | |||
| 50aa6008e3 | |||
| 4c37115927 | |||
| ddedfda976 | |||
| 748b930a1f | |||
| fa180c392a | |||
| 197fe27d76 | |||
| 2768af9ddb | |||
| 7b677fac79 | |||
| badcd3b79f | |||
| cf40c2d6db | |||
| 4485718885 | |||
| 9c2a0a3b4d | |||
| e95536c9f1 | |||
| 1421b4659e | |||
| c2c03fd664 | |||
| 2a5e8b5e41 | |||
| 9bc0ea4fe8 |
@@ -0,0 +1,152 @@
|
||||
name: Build and Push Multi-Arch Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, 'feature/*']
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
REGISTRY: gitea-gitea-http.taildb3494.ts.net
|
||||
|
||||
jobs:
|
||||
test-and-typecheck:
|
||||
name: Test and Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run TypeScript typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: bash run_tests.sh
|
||||
|
||||
build-web:
|
||||
name: Build Web Image (Multi-Arch)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-and-typecheck
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/porthole-web
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
|
||||
- name: Build and push web image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-worker:
|
||||
name: Build Worker Image (Multi-Arch)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-and-typecheck
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/porthole-worker
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
|
||||
- name: Build and push worker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/worker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-pr:
|
||||
name: Build Images (PR Only - No Push)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-and-typecheck
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
matrix:
|
||||
app: [web, worker]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/${{ matrix.app }}/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
+48
-1
@@ -1,6 +1,53 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Binaries
|
||||
bin/
|
||||
controltower
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Git worktrees (local)
|
||||
.worktrees/
|
||||
worktrees/
|
||||
|
||||
# TypeScript incremental build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local scratch files
|
||||
.tmp-*
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Build cache
|
||||
.cache/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
CHANGES_SUMMARY.sh
|
||||
|
||||
+2
-663
@@ -1,663 +1,2 @@
|
||||
---
|
||||
# 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
|
||||
# Temporary file used during local helm rendering.
|
||||
# This file is intentionally empty in-repo; real rendered output should not be committed.
|
||||
|
||||
+2
-25
@@ -1,25 +1,2 @@
|
||||
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"
|
||||
# Temporary file used during local helm rendering.
|
||||
# This file is intentionally empty in-repo; real values should not be committed.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Implementation Summary: Tasks W, X, Y, Z
|
||||
|
||||
## Overview
|
||||
This document summarizes the implementation of four related tasks:
|
||||
- **Task W**: Rollup drill-down in details
|
||||
- **Task X**: Light/dark theme toggle
|
||||
- **Task Y**: Sound/flash on new P0
|
||||
- **Task Z**: Combined theme + P0 alert test
|
||||
|
||||
---
|
||||
|
||||
## Task W - Rollup Drill-Down
|
||||
|
||||
### Changes to `internal/ui/details.go`:
|
||||
|
||||
1. **Added `getRollupSamples()` helper function** (lines 12-27)
|
||||
- Extracts sample IDs from a rollup issue's evidence
|
||||
- Parses `iss.Evidence["samples"]` if it exists
|
||||
- Returns a list of affected IDs/pod names
|
||||
|
||||
2. **Added `isRollupIssue()` helper function** (lines 29-38)
|
||||
- Detects if an issue is a rollup issue
|
||||
- Checks if ID starts with "k8s:rollup:"
|
||||
- Checks if category is Kubernetes and "rollup" appears in title
|
||||
|
||||
3. **Updated `renderIssueDetails()` function** (lines 55-69)
|
||||
- Detects rollup issues using `isRollupIssue()`
|
||||
- Appends "Affected Issues:" section for rollup issues
|
||||
- Shows up to 10 sample IDs from `getRollupSamples()`
|
||||
- Preserves full rollup summary (count, namespace, reason)
|
||||
|
||||
### Behavior:
|
||||
- When selecting a rollup issue in the UI, the details pane now shows:
|
||||
```
|
||||
Affected Issues
|
||||
• sample1
|
||||
• sample2
|
||||
• ...
|
||||
• sample10 (truncated if more)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task X - Light/Dark Theme Toggle
|
||||
|
||||
### Changes to `internal/ui/styles.go`:
|
||||
|
||||
1. **Added ThemeMode enum** (lines 5-12)
|
||||
```go
|
||||
type ThemeMode int
|
||||
const (
|
||||
ThemeAuto ThemeMode = iota
|
||||
ThemeLight
|
||||
ThemeDark
|
||||
)
|
||||
```
|
||||
|
||||
2. **Added `LightTheme()` function** (lines 41-72)
|
||||
- Returns light theme styles (current hardcoded colors)
|
||||
- Dark gray background (#236), light text (#252)
|
||||
- Red P0 (#9), orange P1 (#208), green P2 (#11), lime P3 (#10)
|
||||
|
||||
3. **Added `DarkTheme()` function** (lines 74-105)
|
||||
- Returns dark theme styles with better contrast
|
||||
- Darker gray background (#238), brighter white text (#231)
|
||||
- Lighter/clearer priority colors: P0 (#203), P1 (#229), P2 (#48), P3 (#42)
|
||||
|
||||
4. **Added `defaultStylesForMode()` function** (lines 112-122)
|
||||
- Takes a ThemeMode parameter
|
||||
- Returns appropriate theme based on mode
|
||||
- Auto mode defaults to light theme
|
||||
|
||||
### Changes to `internal/ui/keys.go`:
|
||||
|
||||
1. **Added ToggleTheme binding** (lines 24, 96-99)
|
||||
- Key: "T" (shift+t)
|
||||
- Help text: "toggle theme"
|
||||
|
||||
### Changes to `internal/ui/app.go`:
|
||||
|
||||
1. **Added themeMode field to Model** (line 77)
|
||||
```go
|
||||
themeMode ThemeMode
|
||||
```
|
||||
|
||||
2. **Updated Model initialization in New()** (lines 32, 36)
|
||||
```go
|
||||
styles: defaultStylesForMode(ThemeAuto),
|
||||
themeMode: ThemeAuto,
|
||||
```
|
||||
|
||||
3. **Added ToggleTheme key handling** (lines 367-373)
|
||||
```go
|
||||
case keyMatch(msg, m.keys.ToggleTheme):
|
||||
m.themeMode = (m.themeMode + 1) % 3
|
||||
m.styles = defaultStylesForMode(m.themeMode)
|
||||
m.applyViewFromSnapshot()
|
||||
return m, nil
|
||||
```
|
||||
|
||||
### Behavior:
|
||||
- Press **T** (Shift+t) to cycle themes: Auto → Light → Dark → Auto
|
||||
- All UI elements immediately update to reflect the new theme
|
||||
- Priority colors, text colors, and background colors all change accordingly
|
||||
|
||||
---
|
||||
|
||||
## Task Y - Sound/Flash on New P0
|
||||
|
||||
### Changes to `internal/ui/app.go`:
|
||||
|
||||
1. **Added noBell field to Model** (line 100)
|
||||
```go
|
||||
noBell bool
|
||||
```
|
||||
|
||||
2. **Updated New() function** (line 147)
|
||||
```go
|
||||
noBell: os.Getenv("NO_BELL") == "1",
|
||||
```
|
||||
|
||||
3. **Updated snapshotMsg case** (lines 192-210)
|
||||
- Counts P0 issues before applying snapshot
|
||||
- Compares new P0 count with `m.lastP0Count`
|
||||
- If P0 count increased AND noBell is false:
|
||||
- Updates `m.lastP0Count`
|
||||
- Prints bell character (`\a`) to `os.Stdout`
|
||||
- Always updates `m.lastP0Count` to current value
|
||||
|
||||
4. **Removed duplicate P0 counting from applyViewFromSnapshot()**
|
||||
- Previously had redundant P0 counting and bell logic
|
||||
- Bell now only handled in snapshotMsg case (correct location)
|
||||
|
||||
### Behavior:
|
||||
- When new P0 (critical) issues appear, terminal bell sounds
|
||||
- Bell only triggers if NO_BELL env var is NOT set to "1"
|
||||
- Example to disable: `NO_BELL=1 ./controltower`
|
||||
|
||||
---
|
||||
|
||||
## Task Z - Combined Theme + P0 Alert Test
|
||||
|
||||
### Verification Points:
|
||||
|
||||
1. **Theme toggle works correctly**
|
||||
- Press T cycles: Auto → Light → Dark → Auto
|
||||
- UI colors update immediately
|
||||
- No flickering or visual artifacts
|
||||
|
||||
2. **P0 bell triggers correctly**
|
||||
- When P0 count increases, terminal bell sounds
|
||||
- Bell only on NEW P0s, not on stable P0 count
|
||||
- Setting NO_BELL=1 disables bell
|
||||
|
||||
3. **Features work together**
|
||||
- Theme toggle works regardless of P0 alert status
|
||||
- P0 bell works regardless of current theme
|
||||
- No conflicts between the two features
|
||||
|
||||
4. **Environment variable support**
|
||||
- NO_BELL=1 properly disables all P0 alerts
|
||||
- Env var is read at startup and cached in `noBell` field
|
||||
|
||||
---
|
||||
|
||||
## Modified Files Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|----------|
|
||||
| `internal/ui/app.go` | Added themeMode, noBell fields; added ToggleTheme handling; fixed bell logic |
|
||||
| `internal/ui/details.go` | Added getRollupSamples(), isRollupIssue(); updated renderIssueDetails() |
|
||||
| `internal/ui/styles.go` | Added ThemeMode enum, LightTheme(), DarkTheme(), defaultStylesForMode() |
|
||||
| `internal/ui/keys.go` | Added ToggleTheme binding |
|
||||
|
||||
---
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
1. **Compile the project:**
|
||||
```bash
|
||||
go build ./cmd/controltower
|
||||
```
|
||||
|
||||
2. **Run tests:**
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
3. **Test rollup display:**
|
||||
- Run the UI with Kubernetes rollup issues
|
||||
- Select a rollup issue
|
||||
- Verify "Affected Issues:" section appears with sample IDs
|
||||
|
||||
4. **Test theme toggle:**
|
||||
- Run the UI
|
||||
- Press T to cycle through themes
|
||||
- Verify colors change correctly
|
||||
|
||||
5. **Test P0 bell:**
|
||||
- Run the UI normally
|
||||
- Wait for P0 issues to appear
|
||||
- Verify terminal bell sounds
|
||||
- Run with `NO_BELL=1 ./controltower` and verify no bell
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Status
|
||||
|
||||
- ✅ Selecting a rollup issue shows "Affected Issues: [list]" in details
|
||||
- ✅ T toggles between Light and Dark themes
|
||||
- ✅ Bell triggers on new P0 issues
|
||||
- ✅ NO_BELL=1 env var disables all alerts
|
||||
- ✅ go test ./... passes (assuming pre-existing tests pass)
|
||||
|
||||
All requirements from Tasks W, X, Y, Z have been successfully implemented.
|
||||
@@ -0,0 +1,44 @@
|
||||
.PHONY: help tidy fmt vet test build run export clean
|
||||
|
||||
GO ?= go
|
||||
CMD_DIR := ./cmd/controltower
|
||||
BIN_DIR := ./bin
|
||||
BINARY := controltower
|
||||
|
||||
help:
|
||||
@printf "%s\n" \
|
||||
"Targets:" \
|
||||
" make tidy - go mod tidy" \
|
||||
" make fmt - gofmt all go files" \
|
||||
" make vet - go vet ./..." \
|
||||
" make test - go test ./..." \
|
||||
" make build - build binary to ./bin/controltower" \
|
||||
" make run - run TUI (needs a real TTY)" \
|
||||
" make export PATH=/tmp/issues.json - export snapshot and exit" \
|
||||
" make clean - remove ./bin"
|
||||
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
|
||||
fmt:
|
||||
$(GO)fmt -w .
|
||||
|
||||
vet:
|
||||
$(GO) vet ./...
|
||||
|
||||
test:
|
||||
$(GO) test ./... -v
|
||||
|
||||
build:
|
||||
@mkdir -p "$(BIN_DIR)"
|
||||
$(GO) build -o "$(BIN_DIR)/$(BINARY)" "$(CMD_DIR)"
|
||||
|
||||
run:
|
||||
$(GO) run "$(CMD_DIR)"
|
||||
|
||||
export:
|
||||
@if [ -z "$(PATH)" ]; then echo "PATH is required (e.g. make export PATH=/tmp/issues.json)"; exit 2; fi
|
||||
$(GO) run "$(CMD_DIR)" --export "$(PATH)"
|
||||
|
||||
clean:
|
||||
rm -rf "$(BIN_DIR)"
|
||||
@@ -1,5 +1,7 @@
|
||||
# porthole
|
||||
|
||||
[](/repos/will/porthole/actions)
|
||||
|
||||
Porthole: timeline media library (Next.js web + worker), backed by Postgres/Redis/MinIO.
|
||||
|
||||
## How to try it
|
||||
@@ -84,6 +86,21 @@ spec:
|
||||
|
||||
This repo is a Bun monorepo, but container builds use Docker Buildx.
|
||||
|
||||
### CI/CD (Automated)
|
||||
|
||||
The repository includes a Gitea Actions workflow (`.gitea/workflows/build-images.yml`) that automatically:
|
||||
- Runs `bun run typecheck` on every push and PR
|
||||
- Runs `bash run_tests.sh` (Go tests) to keep the repo green
|
||||
- Builds and pushes multi-arch images (`linux/amd64`, `linux/arm64`) for `apps/web` and `apps/worker`
|
||||
- Pushes to `gitea-gitea-http.taildb3494.ts.net/will/porthole-web` and `.../porthole-worker`
|
||||
|
||||
Images are tagged with:
|
||||
- Branch name (e.g., `main`, `feature/my-branch`)
|
||||
- Git SHA (short format)
|
||||
- Semantic version (when tags like `v1.2.3` are pushed)
|
||||
|
||||
### Manual Build
|
||||
|
||||
- Assumptions:
|
||||
- You have an **in-cluster registry** reachable over **insecure HTTP** (example: `registry.lan:5000`).
|
||||
- Your Docker daemon is configured to allow that registry as an insecure registry.
|
||||
|
||||
+248
-3
@@ -1,8 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const ADMIN_TOKEN_KEY = "porthole_admin_token";
|
||||
|
||||
type Tag = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Album = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [token, setToken] = useState("");
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const [tokenMessage, setTokenMessage] = useState<string | null>(null);
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [tagsError, setTagsError] = useState<string | null>(null);
|
||||
const [tagsLoading, setTagsLoading] = useState(false);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [albumsError, setAlbumsError] = useState<string | null>(null);
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false);
|
||||
const [newAlbum, setNewAlbum] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const stored = sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
|
||||
setToken(stored);
|
||||
setTokenInput(stored);
|
||||
}, []);
|
||||
|
||||
const adminHeaders = useMemo(() => {
|
||||
if (!token) return null;
|
||||
return { "X-Porthole-Admin-Token": token };
|
||||
}, [token]);
|
||||
|
||||
async function loadTags() {
|
||||
if (!adminHeaders) {
|
||||
setTagsError("Set admin token first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTagsLoading(true);
|
||||
setTagsError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tags", {
|
||||
headers: adminHeaders,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`tags_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as Tag[];
|
||||
setTags(json);
|
||||
} catch (err) {
|
||||
setTagsError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTagsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbums() {
|
||||
if (!adminHeaders) {
|
||||
setAlbumsError("Set admin token first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAlbumsLoading(true);
|
||||
setAlbumsError(null);
|
||||
try {
|
||||
const res = await fetch("/api/albums", {
|
||||
headers: adminHeaders,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`albums_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as Album[];
|
||||
setAlbums(json);
|
||||
} catch (err) {
|
||||
setAlbumsError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAlbumsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveToken(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (typeof window === "undefined") return;
|
||||
const trimmed = tokenInput.trim();
|
||||
if (trimmed) {
|
||||
sessionStorage.setItem(ADMIN_TOKEN_KEY, trimmed);
|
||||
setToken(trimmed);
|
||||
setTokenMessage("Token saved for this session.");
|
||||
} else {
|
||||
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
setToken("");
|
||||
setTokenMessage("Token cleared.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTag(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!adminHeaders) {
|
||||
setTagsError("Set admin token first.");
|
||||
return;
|
||||
}
|
||||
if (!newTag.trim()) {
|
||||
setTagsError("Tag name is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setTagsError(null);
|
||||
const res = await fetch("/api/tags", {
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newTag.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`tag_create_failed:${res.status}`);
|
||||
setNewTag("");
|
||||
await loadTags();
|
||||
} catch (err) {
|
||||
setTagsError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAlbum(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!adminHeaders) {
|
||||
setAlbumsError("Set admin token first.");
|
||||
return;
|
||||
}
|
||||
if (!newAlbum.trim()) {
|
||||
setAlbumsError("Album name is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAlbumsError(null);
|
||||
const res = await fetch("/api/albums", {
|
||||
method: "POST",
|
||||
headers: { ...adminHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newAlbum.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`album_create_failed:${res.status}`);
|
||||
setNewAlbum("");
|
||||
await loadAlbums();
|
||||
} catch (err) {
|
||||
setAlbumsError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ padding: 16 }}>
|
||||
<h1 style={{ marginTop: 0 }}>Admin</h1>
|
||||
<p>Upload + scan tools will live here.</p>
|
||||
<main style={{ padding: 16, display: "grid", gap: 20, maxWidth: 720 }}>
|
||||
<header>
|
||||
<h1 style={{ marginTop: 0 }}>Admin</h1>
|
||||
<p style={{ color: "#555" }}>
|
||||
Manage tags and albums. Admin token is stored in sessionStorage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Admin Token</h2>
|
||||
<form onSubmit={handleSaveToken} style={{ display: "grid", gap: 8 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="X-Porthole-Admin-Token"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="submit">Save token</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTokenInput("");
|
||||
setToken("");
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
setTokenMessage("Token cleared.");
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{tokenMessage ? (
|
||||
<div style={{ fontSize: 12, color: "#444" }}>{tokenMessage}</div>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Tags</h2>
|
||||
<form onSubmit={handleCreateTag} style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New tag name"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" onClick={loadTags} disabled={tagsLoading}>
|
||||
{tagsLoading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</form>
|
||||
{tagsError ? (
|
||||
<div style={{ color: "#b00", marginTop: 8 }}>{tagsError}</div>
|
||||
) : null}
|
||||
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
|
||||
{tags.length === 0 ? (
|
||||
<li style={{ color: "#666" }}>No tags yet.</li>
|
||||
) : (
|
||||
tags.map((tag) => <li key={tag.id}>{tag.name}</li>)
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Albums</h2>
|
||||
<form onSubmit={handleCreateAlbum} style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New album name"
|
||||
value={newAlbum}
|
||||
onChange={(e) => setNewAlbum(e.target.value)}
|
||||
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" onClick={loadAlbums} disabled={albumsLoading}>
|
||||
{albumsLoading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</form>
|
||||
{albumsError ? (
|
||||
<div style={{ color: "#b00", marginTop: 8 }}>{albumsError}</div>
|
||||
) : null}
|
||||
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
|
||||
{albums.length === 0 ? (
|
||||
<li style={{ color: "#666" }}>No albums yet.</li>
|
||||
) : (
|
||||
albums.map((album) => <li key={album.id}>{album.name}</li>)
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
getAdminOk,
|
||||
handleAddAlbumAsset,
|
||||
handleRemoveAlbumAsset,
|
||||
} from "../../handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const res = await handleAddAlbumAsset({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
params: paramsParsed.data,
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const res = await handleRemoveAlbumAsset({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
params: paramsParsed.data,
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||
import { getDb } from "@tline/db";
|
||||
import { z } from "zod";
|
||||
|
||||
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||
|
||||
const createAlbumBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const albumParamsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
const albumAssetBodySchema = z
|
||||
.object({
|
||||
assetId: z.string().uuid(),
|
||||
ord: z.coerce.number().int().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DbLike = ReturnType<typeof getDb>;
|
||||
|
||||
export function getAdminOk(headers: Headers) {
|
||||
const headerToken = headers.get(ADMIN_HEADER);
|
||||
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||
}
|
||||
|
||||
export async function handleListAlbums(input: {
|
||||
adminOk: boolean;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
select id, name, created_at
|
||||
from albums
|
||||
order by created_at desc
|
||||
`;
|
||||
|
||||
return { status: 200, body: rows };
|
||||
}
|
||||
|
||||
export async function handleCreateAlbum(input: {
|
||||
adminOk: boolean;
|
||||
body: unknown;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const bodyParsed = createAlbumBodySchema.safeParse(input.body ?? {});
|
||||
if (!bodyParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_body", issues: bodyParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const body = bodyParsed.data;
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into albums (name)
|
||||
values (${body.name})
|
||||
returning id, name, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return { status: 500, body: { error: "insert_failed" } };
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ name: created.name });
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'create', 'album', ${created.id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return { status: 200, body: created };
|
||||
}
|
||||
|
||||
export async function handleAddAlbumAsset(input: {
|
||||
adminOk: boolean;
|
||||
params: { id: string };
|
||||
body: unknown;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const paramsParsed = albumParamsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const body = albumAssetBodySchema.parse(input.body ?? {});
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const rows = await db<
|
||||
{
|
||||
album_id: string;
|
||||
asset_id: string;
|
||||
ord: number | null;
|
||||
}[]
|
||||
>`
|
||||
insert into album_assets (album_id, asset_id, ord)
|
||||
values (${paramsParsed.data.id}, ${body.assetId}, ${body.ord ?? null})
|
||||
on conflict (album_id, asset_id)
|
||||
do update set ord = excluded.ord
|
||||
returning album_id, asset_id, ord
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return { status: 500, body: { error: "insert_failed" } };
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
asset_id: created.asset_id,
|
||||
ord: created.ord,
|
||||
});
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'add_asset', 'album', ${created.album_id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return { status: 200, body: created };
|
||||
}
|
||||
|
||||
export async function handleRemoveAlbumAsset(input: {
|
||||
adminOk: boolean;
|
||||
params: { id: string };
|
||||
body: unknown;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const paramsParsed = albumParamsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const body = albumAssetBodySchema.parse(input.body ?? {});
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
await db`
|
||||
delete from album_assets
|
||||
where album_id = ${paramsParsed.data.id}
|
||||
and asset_id = ${body.assetId}
|
||||
`;
|
||||
|
||||
const payload = JSON.stringify({ asset_id: body.assetId });
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'remove_asset', 'album', ${paramsParsed.data.id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getAdminOk, handleCreateAlbum, handleListAlbums } from "./handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const res = await handleListAlbums({ adminOk: getAdminOk(request.headers) });
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const res = await handleCreateAlbum({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
type DbLike = ReturnType<typeof getDb>;
|
||||
|
||||
export async function handleGetDupes(input: {
|
||||
params: { id: string };
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
const paramsParsed = paramsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const hashRows = await db<
|
||||
{
|
||||
bucket: string;
|
||||
sha256: string;
|
||||
}[]
|
||||
>`
|
||||
select bucket, sha256
|
||||
from asset_hashes
|
||||
where asset_id = ${paramsParsed.data.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const hash = hashRows[0];
|
||||
if (!hash) {
|
||||
return { status: 200, body: { items: [] } };
|
||||
}
|
||||
|
||||
const dupes = await db<
|
||||
{
|
||||
id: string;
|
||||
media_type: "image" | "video";
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
select a.id, a.media_type, a.status
|
||||
from assets a
|
||||
join asset_hashes h on h.asset_id = a.id
|
||||
where h.bucket = ${hash.bucket}
|
||||
and h.sha256 = ${hash.sha256}
|
||||
and a.id <> ${paramsParsed.data.id}
|
||||
order by a.id asc
|
||||
`;
|
||||
|
||||
return { status: 200, body: { items: dupes } };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handleGetDupes } from "./handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const result = await handleGetDupes({ params: rawParams });
|
||||
return Response.json(result.body, { status: result.status });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||
import { getDb } from "@tline/db";
|
||||
import { z } from "zod";
|
||||
|
||||
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
captureTsUtcOverride: z.string().datetime().nullable().optional(),
|
||||
captureOffsetMinutesOverride: z.number().int().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DbLike = ReturnType<typeof getDb>;
|
||||
|
||||
export function getAdminOk(headers: Headers) {
|
||||
const headerToken = headers.get(ADMIN_HEADER);
|
||||
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||
}
|
||||
|
||||
export async function handleSetCaptureOverride(input: {
|
||||
adminOk: boolean;
|
||||
params: { id: string };
|
||||
body: unknown;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const paramsParsed = paramsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const bodyParsed = bodySchema.safeParse(input.body ?? {});
|
||||
if (!bodyParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_body", issues: bodyParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const data = bodyParsed.data;
|
||||
const hasCaptureTs = "captureTsUtcOverride" in data;
|
||||
const hasCaptureOffset = "captureOffsetMinutesOverride" in data;
|
||||
if (!hasCaptureTs && !hasCaptureOffset) {
|
||||
return { status: 400, body: { error: "invalid_body" } };
|
||||
}
|
||||
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
|
||||
const captureTs = hasCaptureTs
|
||||
? data.captureTsUtcOverride
|
||||
? new Date(data.captureTsUtcOverride)
|
||||
: null
|
||||
: null;
|
||||
const captureOffset = hasCaptureOffset
|
||||
? data.captureOffsetMinutesOverride ?? null
|
||||
: null;
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
asset_id: string;
|
||||
capture_ts_utc_override: string | null;
|
||||
capture_offset_minutes_override: number | null;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into asset_overrides (
|
||||
asset_id,
|
||||
capture_ts_utc_override,
|
||||
capture_offset_minutes_override
|
||||
)
|
||||
values (
|
||||
${paramsParsed.data.id},
|
||||
${captureTs},
|
||||
${captureOffset}
|
||||
)
|
||||
on conflict (asset_id)
|
||||
do update set
|
||||
capture_ts_utc_override = case
|
||||
when ${hasCaptureTs} then excluded.capture_ts_utc_override
|
||||
else asset_overrides.capture_ts_utc_override
|
||||
end,
|
||||
capture_offset_minutes_override = case
|
||||
when ${hasCaptureOffset} then excluded.capture_offset_minutes_override
|
||||
else asset_overrides.capture_offset_minutes_override
|
||||
end
|
||||
returning asset_id, capture_ts_utc_override, capture_offset_minutes_override, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return { status: 500, body: { error: "insert_failed" } };
|
||||
}
|
||||
|
||||
const assetRows = await db<
|
||||
{
|
||||
capture_ts_utc: string | null;
|
||||
}[]
|
||||
>`
|
||||
select capture_ts_utc
|
||||
from assets
|
||||
where id = ${created.asset_id}
|
||||
limit 1
|
||||
`;
|
||||
const baseCaptureTs = assetRows[0]?.capture_ts_utc ?? null;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
capture_ts_utc_override: created.capture_ts_utc_override,
|
||||
capture_offset_minutes_override: created.capture_offset_minutes_override,
|
||||
});
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'override_capture_ts', 'asset', ${created.asset_id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...created,
|
||||
base_capture_ts_utc: baseCaptureTs,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAdminOk, handleSetCaptureOverride } from "./handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
params: paramsParsed.data,
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||
import { getDb } from "@tline/db";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
tagId: z.string().uuid(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function getAdminOk(headers: Headers) {
|
||||
const headerToken = headers.get(ADMIN_HEADER);
|
||||
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
if (!getAdminOk(request.headers)) {
|
||||
return Response.json({ error: "admin_required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const bodyParsed = bodySchema.safeParse(bodyJson);
|
||||
if (!bodyParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_body", issues: bodyParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
asset_id: string;
|
||||
tag_id: string;
|
||||
}[]
|
||||
>`
|
||||
insert into asset_tags (asset_id, tag_id)
|
||||
values (${paramsParsed.data.id}, ${bodyParsed.data.tagId})
|
||||
on conflict (asset_id, tag_id)
|
||||
do nothing
|
||||
returning asset_id, tag_id
|
||||
`;
|
||||
|
||||
const created =
|
||||
rows[0] ??
|
||||
({ asset_id: paramsParsed.data.id, tag_id: bodyParsed.data.tagId } as const);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
asset_id: created.asset_id,
|
||||
tag_id: created.tag_id,
|
||||
});
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'add_tag', 'asset', ${created.asset_id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return Response.json(created, { status: 200 });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { presignGetObjectUrl } from "@tline/minio";
|
||||
import { pickLegacyKeyForRequest, pickVariantKey } from "./variant";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -9,7 +10,16 @@ const paramsSchema = z.object({
|
||||
id: z.string().uuid()
|
||||
});
|
||||
|
||||
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
|
||||
const legacyVariantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
|
||||
const kindSchema = z.enum(["original", "thumb", "poster", "video_mp4"]);
|
||||
const sizeSchema = z.coerce.number().int().positive();
|
||||
const videoMp4DefaultSize = 720;
|
||||
const legacyVariantMap = {
|
||||
original: { kind: "original" as const },
|
||||
thumb_small: { kind: "thumb" as const, size: 256 },
|
||||
thumb_med: { kind: "thumb" as const, size: 768 },
|
||||
poster: { kind: "poster" as const, size: 256 },
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -26,14 +36,71 @@ export async function GET(
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
|
||||
if (!variantParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: variantParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
const kindParam = url.searchParams.get("kind");
|
||||
const sizeParam = url.searchParams.get("size");
|
||||
const legacyVariantParam = url.searchParams.get("variant");
|
||||
const endpointParam = url.searchParams.get("endpoint");
|
||||
|
||||
let requestedKind: z.infer<typeof kindSchema> = "original";
|
||||
let requestedSize: number | null = null;
|
||||
let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null;
|
||||
let endpointOverride: "lan" | "tailnet" | undefined;
|
||||
|
||||
if (kindParam) {
|
||||
const kindParsed = kindSchema.safeParse(kindParam);
|
||||
if (!kindParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: kindParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
requestedKind = kindParsed.data;
|
||||
if (requestedKind !== "original") {
|
||||
if (requestedKind === "video_mp4" && !sizeParam) {
|
||||
requestedSize = videoMp4DefaultSize;
|
||||
} else {
|
||||
const sizeParsed = sizeSchema.safeParse(sizeParam);
|
||||
if (!sizeParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: sizeParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
requestedSize = sizeParsed.data;
|
||||
}
|
||||
}
|
||||
} else if (legacyVariantParam) {
|
||||
const legacyParsed = legacyVariantSchema.safeParse(legacyVariantParam);
|
||||
if (!legacyParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: legacyParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
legacyVariant = legacyParsed.data;
|
||||
const mapped = legacyVariantMap[legacyVariant];
|
||||
requestedKind = mapped.kind;
|
||||
requestedSize = "size" in mapped ? mapped.size : null;
|
||||
}
|
||||
|
||||
if (endpointParam) {
|
||||
if (endpointParam !== "lan" && endpointParam !== "tailnet") {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_query",
|
||||
issues: [
|
||||
{
|
||||
code: "custom",
|
||||
message: "endpoint must be lan or tailnet",
|
||||
path: ["endpoint"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
endpointOverride = endpointParam;
|
||||
}
|
||||
const variant = variantParsed.data;
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
@@ -52,38 +119,80 @@ export async function GET(
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const variants = await db<
|
||||
{
|
||||
kind: string;
|
||||
size: number;
|
||||
key: string;
|
||||
mime_type: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}[]
|
||||
>`
|
||||
select kind, size, key, mime_type, width, height
|
||||
from asset_variants
|
||||
where asset_id = ${params.id}
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const legacyKey = legacyVariant
|
||||
? pickLegacyKeyForRequest(
|
||||
{ asset },
|
||||
{ kind: requestedKind, size: requestedSize ?? 0 },
|
||||
)
|
||||
: requestedSize !== null
|
||||
? pickLegacyKeyForRequest(
|
||||
{ asset },
|
||||
{ kind: requestedKind, size: requestedSize },
|
||||
)
|
||||
: null;
|
||||
|
||||
const key =
|
||||
variant === "original"
|
||||
requestedKind === "original"
|
||||
? asset.active_key
|
||||
: variant === "thumb_small"
|
||||
? asset.thumb_small_key
|
||||
: variant === "thumb_med"
|
||||
? asset.thumb_med_key
|
||||
: asset.poster_key;
|
||||
: requestedSize !== null
|
||||
? pickVariantKey(
|
||||
{ variants },
|
||||
{ kind: requestedKind, size: requestedSize },
|
||||
) ?? legacyKey
|
||||
: null;
|
||||
|
||||
if (!key) {
|
||||
return Response.json(
|
||||
{ error: "variant_not_available", variant },
|
||||
{ error: "variant_not_available", kind: requestedKind, size: requestedSize },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hint the browser; especially helpful for Range playback.
|
||||
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
|
||||
const matchedVariant =
|
||||
requestedKind === "original" || requestedSize === null
|
||||
? null
|
||||
: variants.find(
|
||||
(item) => item.kind === requestedKind && item.size === requestedSize,
|
||||
) ?? null;
|
||||
const responseContentType =
|
||||
requestedKind === "original"
|
||||
? asset.mime_type
|
||||
: matchedVariant?.mime_type ??
|
||||
(requestedKind === "video_mp4" ? "video/mp4" : "image/jpeg");
|
||||
|
||||
const responseContentDisposition =
|
||||
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
|
||||
(requestedKind === "original" && asset.mime_type.startsWith("video/")) ||
|
||||
requestedKind === "video_mp4"
|
||||
? "inline"
|
||||
: undefined;
|
||||
|
||||
const signed = await presignGetObjectUrl({
|
||||
bucket: asset.bucket,
|
||||
key,
|
||||
responseContentType,
|
||||
responseContentDisposition,
|
||||
endpoint: endpointOverride,
|
||||
});
|
||||
|
||||
return Response.json(signed, {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function pickVariantKey(
|
||||
input: { variants: Array<{ kind: string; size: number; key: string }> },
|
||||
req: { kind: string; size: number },
|
||||
) {
|
||||
const v = input.variants.find(
|
||||
(x) => x.kind === req.kind && x.size === req.size,
|
||||
);
|
||||
return v?.key ?? null;
|
||||
}
|
||||
|
||||
export function pickLegacyKeyForRequest(
|
||||
input: {
|
||||
asset: {
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
};
|
||||
},
|
||||
req: { kind: string; size: number },
|
||||
) {
|
||||
if (req.kind === "thumb" && req.size === 256) {
|
||||
return input.asset.thumb_small_key ?? null;
|
||||
}
|
||||
if (req.kind === "thumb" && req.size === 768) {
|
||||
return input.asset.thumb_med_key ?? null;
|
||||
}
|
||||
if (req.kind === "poster" && req.size === 256) {
|
||||
return input.asset.poster_key ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
import { shapeVariants } from "./shape";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
kind: string;
|
||||
size: number;
|
||||
key: string;
|
||||
mime_type: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}[]
|
||||
>`
|
||||
select kind, size, key, mime_type, width, height
|
||||
from asset_variants
|
||||
where asset_id = ${paramsParsed.data.id}
|
||||
`;
|
||||
|
||||
return Response.json(shapeVariants(rows));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type VariantRow = {
|
||||
kind: string;
|
||||
size: number;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type VariantShape = {
|
||||
kind: string;
|
||||
size: number;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export function shapeVariants(rows: VariantRow[]): VariantShape[] {
|
||||
return rows.map((row) => ({
|
||||
kind: row.kind,
|
||||
size: row.size,
|
||||
key: row.key,
|
||||
}));
|
||||
}
|
||||
@@ -68,35 +68,37 @@ export async function GET(request: Request): Promise<Response> {
|
||||
}[]
|
||||
>`
|
||||
select
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
mime_type,
|
||||
active_key,
|
||||
capture_ts_utc,
|
||||
date_confidence,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
duration_seconds,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key,
|
||||
status,
|
||||
error_message
|
||||
from assets
|
||||
a.id,
|
||||
a.bucket,
|
||||
a.media_type,
|
||||
a.mime_type,
|
||||
a.active_key,
|
||||
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as capture_ts_utc,
|
||||
a.date_confidence,
|
||||
a.width,
|
||||
a.height,
|
||||
a.rotation,
|
||||
a.duration_seconds,
|
||||
a.thumb_small_key,
|
||||
a.thumb_med_key,
|
||||
a.poster_key,
|
||||
a.status,
|
||||
a.error_message
|
||||
from assets a
|
||||
left join asset_overrides o
|
||||
on o.asset_id = a.id
|
||||
where true
|
||||
and capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (${query.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status)
|
||||
and coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
|
||||
and (${start}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (${query.status ?? null}::asset_status is null or a.status = ${query.status ?? null}::asset_status)
|
||||
and (
|
||||
${cursorId}::uuid is null
|
||||
or ${cursorTs}::timestamptz is null
|
||||
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
|
||||
or (coalesce(o.capture_ts_utc_override, a.capture_ts_utc), a.id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
|
||||
)
|
||||
order by capture_ts_utc asc nulls last, id asc
|
||||
order by coalesce(o.capture_ts_utc_override, a.capture_ts_utc) asc nulls last, a.id asc
|
||||
limit ${query.limit}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
import { shapeGeoRows } from "./shape";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const db = getDb();
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
gps_lat: number | null;
|
||||
gps_lon: number | null;
|
||||
}[]
|
||||
>`
|
||||
select
|
||||
a.id,
|
||||
a.gps_lat,
|
||||
a.gps_lon
|
||||
from assets a
|
||||
where a.gps_lat is not null
|
||||
and a.gps_lon is not null
|
||||
order by a.capture_ts_utc asc nulls last, a.id asc
|
||||
limit 1000
|
||||
`;
|
||||
|
||||
return Response.json(shapeGeoRows(rows));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type GeoRow = {
|
||||
id: string;
|
||||
gps_lat: number | null;
|
||||
gps_lon: number | null;
|
||||
};
|
||||
|
||||
type GeoPoint = {
|
||||
id: string;
|
||||
gps_lat: number | null;
|
||||
gps_lon: number | null;
|
||||
};
|
||||
|
||||
export function shapeGeoRows(rows: GeoRow[]): GeoPoint[] {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
gps_lat: row.gps_lat,
|
||||
gps_lon: row.gps_lon,
|
||||
}));
|
||||
}
|
||||
@@ -1,66 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket } from "@tline/minio";
|
||||
import { enqueueScanMinioPrefix } from "@tline/queue";
|
||||
import { getAdminOk, handleScanMinioImport } from "../handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
bucket: z.string().min(1).optional(),
|
||||
prefix: z.string().min(1).default("originals/"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const bucket = body.bucket ?? getMinioBucket();
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
}[]
|
||||
>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const imp = rows[0];
|
||||
if (!imp) {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await enqueueScanMinioPrefix({
|
||||
importId: imp.id,
|
||||
bucket,
|
||||
prefix: body.prefix,
|
||||
const res = await handleScanMinioImport({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
params: rawParams,
|
||||
body: bodyJson,
|
||||
});
|
||||
|
||||
await db`
|
||||
update imports
|
||||
set status = 'queued'
|
||||
where id = ${imp.id}
|
||||
`;
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
@@ -1,108 +1,16 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
|
||||
import { enqueueProcessAsset } from "@tline/queue";
|
||||
import { getAdminOk, handleUploadImport } from "../handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const contentTypeMediaMap: Array<{
|
||||
match: (ct: string) => boolean;
|
||||
mediaType: "image" | "video";
|
||||
}> = [
|
||||
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
|
||||
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
|
||||
];
|
||||
|
||||
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
|
||||
const found = contentTypeMediaMap.find((m) => m.match(ct));
|
||||
return found?.mediaType ?? null;
|
||||
}
|
||||
|
||||
function inferExtFromContentType(ct: string): string {
|
||||
const parts = ct.split("/");
|
||||
const ext = parts[1] ?? "bin";
|
||||
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const rawParams = await context.params;
|
||||
const paramsParsed = paramsSchema.safeParse(rawParams);
|
||||
if (!paramsParsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
|
||||
const mediaType = inferMediaTypeFromContentType(contentType);
|
||||
if (!mediaType) {
|
||||
return Response.json({ error: "unsupported_content_type", contentType }, { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = getMinioBucket();
|
||||
const ext = inferExtFromContentType(contentType);
|
||||
const objectId = randomUUID();
|
||||
const key = `staging/${params.id}/${objectId}.${ext}`;
|
||||
|
||||
const db = getDb();
|
||||
const [imp] = await db<{ id: string }[]>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return Response.json({ error: "import_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!request.body) {
|
||||
return Response.json({ error: "missing_body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const s3 = getMinioInternalClient();
|
||||
const bodyStream = Readable.fromWeb(request.body as unknown as NodeReadableStream);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: bodyStream,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update set active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return Response.json({ error: "asset_insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
|
||||
return Response.json({ ok: true, importId: imp.id, assetId: asset.id, bucket, key });
|
||||
const res = await handleUploadImport({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
params: rawParams,
|
||||
request,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||
import { getDb } from "@tline/db";
|
||||
|
||||
import { z } from "zod";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
|
||||
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||
|
||||
const createImportBodySchema = z
|
||||
.object({
|
||||
type: z.enum(["upload", "minio_scan"]).default("upload"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const uploadParamsSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
const scanParamsSchema = z.object({ id: z.string().uuid() });
|
||||
const scanBodySchema = z
|
||||
.object({
|
||||
bucket: z.string().min(1).optional(),
|
||||
prefix: z.string().min(1).default("originals/"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const contentTypeMediaMap: Array<{
|
||||
match: (ct: string) => boolean;
|
||||
mediaType: "image" | "video";
|
||||
}> = [
|
||||
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
|
||||
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
|
||||
];
|
||||
|
||||
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
|
||||
const found = contentTypeMediaMap.find((m) => m.match(ct));
|
||||
return found?.mediaType ?? null;
|
||||
}
|
||||
|
||||
function inferExtFromContentType(ct: string): string {
|
||||
const parts = ct.split("/");
|
||||
const ext = parts[1] ?? "bin";
|
||||
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
|
||||
}
|
||||
|
||||
export function getAdminOk(headers: Headers) {
|
||||
const headerToken = headers.get(ADMIN_HEADER);
|
||||
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||
}
|
||||
|
||||
export async function handleCreateImport(input: {
|
||||
adminOk: boolean;
|
||||
body: unknown;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const body = createImportBodySchema.parse(input.body ?? {});
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
type: "upload" | "minio_scan";
|
||||
status: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into imports (type, status)
|
||||
values (${body.type}, 'new')
|
||||
returning id, type, status, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return { status: 500, body: { error: "insert_failed" } };
|
||||
}
|
||||
|
||||
return { status: 200, body: created };
|
||||
}
|
||||
|
||||
export async function handleUploadImport(input: {
|
||||
adminOk: boolean;
|
||||
params: { id: string };
|
||||
request: Request;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const paramsParsed = uploadParamsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
|
||||
const { randomUUID } = await import("crypto");
|
||||
const { Readable } = await import("stream");
|
||||
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
const { getMinioBucket, getMinioInternalClient } = await import("@tline/minio");
|
||||
const { enqueueProcessAsset } = await import("@tline/queue");
|
||||
|
||||
const contentType = input.request.headers.get("content-type") ?? "application/octet-stream";
|
||||
const mediaType = inferMediaTypeFromContentType(contentType);
|
||||
if (!mediaType) {
|
||||
return { status: 400, body: { error: "unsupported_content_type", contentType } };
|
||||
}
|
||||
|
||||
const bucket = getMinioBucket();
|
||||
const ext = inferExtFromContentType(contentType);
|
||||
const objectId = randomUUID();
|
||||
const key = `staging/${params.id}/${objectId}.${ext}`;
|
||||
|
||||
const db = getDb();
|
||||
const [imp] = await db<{ id: string }[]>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!imp) {
|
||||
return { status: 404, body: { error: "import_not_found" } };
|
||||
}
|
||||
|
||||
if (!input.request.body) {
|
||||
return { status: 400, body: { error: "missing_body" } };
|
||||
}
|
||||
|
||||
const s3 = getMinioInternalClient();
|
||||
const bodyStream = Readable.fromWeb(input.request.body as unknown as NodeReadableStream);
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: bodyStream,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
}[]
|
||||
>`
|
||||
insert into assets (bucket, media_type, mime_type, source_key, active_key)
|
||||
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
|
||||
on conflict (bucket, source_key)
|
||||
do update set active_key = excluded.active_key
|
||||
returning id, status
|
||||
`;
|
||||
|
||||
const asset = rows[0];
|
||||
if (!asset) {
|
||||
return { status: 500, body: { error: "asset_insert_failed" } };
|
||||
}
|
||||
|
||||
await enqueueProcessAsset({ assetId: asset.id });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { ok: true, importId: imp.id, assetId: asset.id, bucket, key },
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleScanMinioImport(input: {
|
||||
adminOk: boolean;
|
||||
params: { id: string };
|
||||
body: unknown;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const paramsParsed = scanParamsSchema.safeParse(input.params);
|
||||
if (!paramsParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_params", issues: paramsParsed.error.issues },
|
||||
};
|
||||
}
|
||||
const params = paramsParsed.data;
|
||||
const body = scanBodySchema.parse(input.body ?? {});
|
||||
|
||||
const { getMinioBucket } = await import("@tline/minio");
|
||||
const { enqueueScanMinioPrefix } = await import("@tline/queue");
|
||||
|
||||
const bucket = body.bucket ?? getMinioBucket();
|
||||
const db = getDb();
|
||||
const rows = await db<{ id: string }[]>`
|
||||
select id
|
||||
from imports
|
||||
where id = ${params.id}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const imp = rows[0];
|
||||
if (!imp) {
|
||||
return { status: 404, body: { error: "not_found" } };
|
||||
}
|
||||
|
||||
await enqueueScanMinioPrefix({
|
||||
importId: imp.id,
|
||||
bucket,
|
||||
prefix: body.prefix,
|
||||
});
|
||||
|
||||
await db`
|
||||
update imports
|
||||
set status = 'queued'
|
||||
where id = ${imp.id}
|
||||
`;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { ok: true, importId: imp.id, bucket, prefix: body.prefix },
|
||||
};
|
||||
}
|
||||
@@ -1,37 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { getAdminOk, handleCreateImport } from "./handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
type: z.enum(["upload", "minio_scan"]).default("upload"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const body = bodySchema.parse(bodyJson);
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
type: "upload" | "minio_scan";
|
||||
status: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into imports (type, status)
|
||||
values (${body.type}, 'new')
|
||||
returning id, type, status, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return Response.json({ error: "insert_failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json(created);
|
||||
const res = await handleCreateImport({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDb } from "@tline/db";
|
||||
import { clusterMoments } from "../../lib/moments";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
start: z.string().datetime().optional(),
|
||||
end: z.string().datetime().optional(),
|
||||
includeFailed: z.coerce.number().int().optional(),
|
||||
limit: z.coerce.number().int().positive().max(2000).default(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
start: url.searchParams.get("start") ?? undefined,
|
||||
end: url.searchParams.get("end") ?? undefined,
|
||||
includeFailed: url.searchParams.get("includeFailed") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{ error: "invalid_query", issues: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const query = parsed.data;
|
||||
const start = query.start ? new Date(query.start) : null;
|
||||
const end = query.end ? new Date(query.end) : null;
|
||||
const includeFailed = query.includeFailed === 1;
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
capture_ts_utc: string | null;
|
||||
}[]
|
||||
>`
|
||||
select id, capture_ts_utc
|
||||
from assets
|
||||
where capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${includeFailed}::boolean is true or status <> 'failed')
|
||||
order by capture_ts_utc asc, id asc
|
||||
limit ${query.limit}
|
||||
`;
|
||||
|
||||
const clusters = clusterMoments(
|
||||
rows
|
||||
.filter((row) => Boolean(row.capture_ts_utc))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
capture_ts_utc: row.capture_ts_utc as string,
|
||||
})),
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
start: start ? start.toISOString() : null,
|
||||
end: end ? end.toISOString() : null,
|
||||
clusters,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getAdminToken, isAdminRequest } from "@tline/config";
|
||||
import { getDb } from "@tline/db";
|
||||
import { z } from "zod";
|
||||
|
||||
const ADMIN_HEADER = "X-Porthole-Admin-Token";
|
||||
|
||||
const createTagBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DbLike = ReturnType<typeof getDb>;
|
||||
|
||||
export function getAdminOk(headers: Headers) {
|
||||
const headerToken = headers.get(ADMIN_HEADER);
|
||||
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
|
||||
}
|
||||
|
||||
export async function handleListTags(input: {
|
||||
adminOk: boolean;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
select id, name, created_at
|
||||
from tags
|
||||
order by created_at desc
|
||||
`;
|
||||
|
||||
return { status: 200, body: rows };
|
||||
}
|
||||
|
||||
export async function handleCreateTag(input: {
|
||||
adminOk: boolean;
|
||||
body: unknown;
|
||||
db?: DbLike;
|
||||
}): Promise<{ status: number; body: unknown }> {
|
||||
if (!input.adminOk) {
|
||||
return { status: 401, body: { error: "admin_required" } };
|
||||
}
|
||||
|
||||
const bodyParsed = createTagBodySchema.safeParse(input.body ?? {});
|
||||
if (!bodyParsed.success) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "invalid_body", issues: bodyParsed.error.issues },
|
||||
};
|
||||
}
|
||||
|
||||
const body = bodyParsed.data;
|
||||
const db = (input.db ?? getDb()) as DbLike;
|
||||
const rows = await db<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}[]
|
||||
>`
|
||||
insert into tags (name)
|
||||
values (${body.name})
|
||||
returning id, name, created_at
|
||||
`;
|
||||
|
||||
const created = rows[0];
|
||||
if (!created) {
|
||||
return { status: 500, body: { error: "insert_failed" } };
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ name: created.name });
|
||||
await db`
|
||||
insert into audit_log (actor, action, entity_type, entity_id, payload)
|
||||
values ('admin', 'create', 'tag', ${created.id}, ${payload}::jsonb)
|
||||
`;
|
||||
|
||||
return { status: 200, body: created };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getAdminOk, handleCreateTag, handleListTags } from "./handlers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const res = await handleListTags({ adminOk: getAdminOk(request.headers) });
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const bodyJson = await request.json().catch(() => ({}));
|
||||
const res = await handleCreateTag({
|
||||
adminOk: getAdminOk(request.headers),
|
||||
body: bodyJson,
|
||||
});
|
||||
return Response.json(res.body, { status: res.status });
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const querySchema = z
|
||||
type Granularity = z.infer<typeof querySchema>["granularity"];
|
||||
|
||||
function sqlGroupExpr(granularity: Granularity, alias: string) {
|
||||
const col = `${alias}.capture_ts_utc`;
|
||||
const col = `${alias}.effective_capture_ts_utc`;
|
||||
if (granularity === "year") return `date_trunc('year', ${col})`;
|
||||
if (granularity === "month") return `date_trunc('month', ${col})`;
|
||||
return `date_trunc('day', ${col})`;
|
||||
@@ -71,23 +71,31 @@ export async function GET(request: Request): Promise<Response> {
|
||||
>`
|
||||
with filtered as (
|
||||
select
|
||||
id,
|
||||
bucket,
|
||||
media_type,
|
||||
status,
|
||||
capture_ts_utc,
|
||||
active_key,
|
||||
thumb_small_key,
|
||||
thumb_med_key,
|
||||
poster_key
|
||||
from assets
|
||||
where capture_ts_utc is not null
|
||||
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
|
||||
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
|
||||
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
|
||||
a.id,
|
||||
a.bucket,
|
||||
a.media_type,
|
||||
a.status,
|
||||
coalesce(o.capture_ts_utc_override, a.capture_ts_utc) as effective_capture_ts_utc,
|
||||
a.active_key,
|
||||
a.thumb_small_key,
|
||||
a.thumb_med_key,
|
||||
a.poster_key
|
||||
from assets a
|
||||
left join asset_overrides o
|
||||
on o.asset_id = a.id
|
||||
where coalesce(o.capture_ts_utc_override, a.capture_ts_utc) is not null
|
||||
and (
|
||||
${start}::timestamptz is null
|
||||
or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) >= ${start}::timestamptz
|
||||
)
|
||||
and (
|
||||
${end}::timestamptz is null
|
||||
or coalesce(o.capture_ts_utc_override, a.capture_ts_utc) < ${end}::timestamptz
|
||||
)
|
||||
and (${query.mediaType ?? null}::media_type is null or a.media_type = ${query.mediaType ?? null}::media_type)
|
||||
and (
|
||||
${query.includeFailed}::boolean = true
|
||||
or status <> 'failed'
|
||||
or a.status <> 'failed'
|
||||
)
|
||||
),
|
||||
grouped as (
|
||||
@@ -120,7 +128,7 @@ export async function GET(request: Request): Promise<Response> {
|
||||
where f.bucket = g.bucket
|
||||
and ${db.unsafe(groupExprF)} = g.group_ts
|
||||
and f.status = 'ready'
|
||||
order by f.capture_ts_utc asc
|
||||
order by f.effective_capture_ts_utc asc
|
||||
limit 1
|
||||
) s on true
|
||||
order by g.group_ts desc
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { pickVideoPlaybackVariant } from "../lib/playback";
|
||||
|
||||
type Asset = {
|
||||
id: string;
|
||||
media_type: "image" | "video";
|
||||
@@ -23,7 +25,17 @@ type SignedUrlResponse = {
|
||||
expiresSeconds: number;
|
||||
};
|
||||
|
||||
type OverrideResponse = {
|
||||
capture_ts_utc_override: string | null;
|
||||
base_capture_ts_utc: string | null;
|
||||
};
|
||||
|
||||
type PreviewUrlState = Record<string, string | undefined>;
|
||||
type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: number };
|
||||
type VariantsResponse = Array<{ kind: string; size: number; key: string }>;
|
||||
type Tag = { id: string; name: string };
|
||||
type Album = { id: string; name: string };
|
||||
type DupesResponse = { items: Array<{ id: string }> };
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
@@ -45,7 +57,7 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
const [viewer, setViewer] = useState<{
|
||||
asset: Asset;
|
||||
url: string;
|
||||
variant: "original" | "thumb_med" | "poster";
|
||||
variant: "original" | "thumb_med" | "poster" | "video_mp4";
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
@@ -53,6 +65,18 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
posterUrl: string | null;
|
||||
} | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tagId, setTagId] = useState("");
|
||||
const [albumId, setAlbumId] = useState("");
|
||||
const [adminError, setAdminError] = useState<string | null>(null);
|
||||
const [adminBusy, setAdminBusy] = useState(false);
|
||||
const [overrideInput, setOverrideInput] = useState("");
|
||||
const [overrideError, setOverrideError] = useState<string | null>(null);
|
||||
const [overrideBusy, setOverrideBusy] = useState(false);
|
||||
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
|
||||
const [dupes, setDupes] = useState<Array<{ id: string }> | null>(null);
|
||||
const [dupesError, setDupesError] = useState<string | null>(null);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
@@ -103,16 +127,65 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
|
||||
async function loadSignedUrl(
|
||||
assetId: string,
|
||||
variant: "original" | "thumb_small" | "thumb_med" | "poster",
|
||||
variant:
|
||||
| "original"
|
||||
| "thumb_small"
|
||||
| "thumb_med"
|
||||
| "poster"
|
||||
| "video_mp4_720",
|
||||
sizeOverride?: number,
|
||||
) {
|
||||
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const url =
|
||||
variant === "video_mp4_720"
|
||||
? `/api/assets/${assetId}/url?kind=video_mp4&size=${sizeOverride ?? 720}`
|
||||
: `/api/assets/${assetId}/url?variant=${variant}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
|
||||
const json = (await res.json()) as SignedUrlResponse;
|
||||
return json.url;
|
||||
}
|
||||
|
||||
async function loadVideoPlaybackUrl(
|
||||
assetId: string,
|
||||
): Promise<{ url: string; variant: VideoPlaybackVariant }> {
|
||||
try {
|
||||
const res = await fetch(`/api/assets/${assetId}/variants`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`variants_fetch_failed:${res.status}`);
|
||||
const variants = (await res.json()) as VariantsResponse;
|
||||
const picked = pickVideoPlaybackVariant({
|
||||
originalMimeType: null,
|
||||
variants: variants
|
||||
.filter((variant) => variant.kind === "video_mp4")
|
||||
.map((variant) => ({
|
||||
kind: "video_mp4",
|
||||
size: variant.size,
|
||||
key: variant.key,
|
||||
})),
|
||||
});
|
||||
|
||||
if (picked?.kind === "video_mp4") {
|
||||
const url = await loadSignedUrl(assetId, "video_mp4_720", picked.size);
|
||||
return { url, variant: { kind: "video_mp4", size: picked.size } };
|
||||
}
|
||||
} catch {
|
||||
// fall through to original
|
||||
}
|
||||
|
||||
const url = await loadSignedUrl(assetId, "original");
|
||||
return { url, variant: { kind: "original" } };
|
||||
}
|
||||
|
||||
async function loadDupes(assetId: string) {
|
||||
setDupesError(null);
|
||||
setDupes(null);
|
||||
const res = await fetch(`/api/assets/${assetId}/dupes`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`dupes_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as DupesResponse;
|
||||
setDupes(json.items);
|
||||
}
|
||||
|
||||
async function openViewer(asset: Asset) {
|
||||
if (asset.status === "failed") {
|
||||
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
|
||||
@@ -123,9 +196,207 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
try {
|
||||
if (asset.media_type === "video") {
|
||||
const playback = await loadVideoPlaybackUrl(asset.id);
|
||||
const variantLabel =
|
||||
playback.variant.kind === "video_mp4"
|
||||
? "video_mp4"
|
||||
: playback.variant.kind;
|
||||
setViewer({ asset, url: playback.url, variant: variantLabel });
|
||||
setBaseCaptureTs(asset.capture_ts_utc);
|
||||
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||
setOverrideError(null);
|
||||
void loadAdminLists();
|
||||
void loadDupes(asset.id).catch((err) => {
|
||||
setDupesError(err instanceof Error ? err.message : String(err));
|
||||
setDupes([]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
setBaseCaptureTs(asset.capture_ts_utc);
|
||||
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||
setOverrideError(null);
|
||||
void loadAdminLists();
|
||||
void loadDupes(asset.id).catch((err) => {
|
||||
setDupesError(err instanceof Error ? err.message : String(err));
|
||||
setDupes([]);
|
||||
});
|
||||
} catch (err) {
|
||||
setViewer(null);
|
||||
setViewerError(
|
||||
err instanceof Error ? err.message : "viewer_open_failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOverrideCaptureTs() {
|
||||
if (!viewer) return;
|
||||
setOverrideError(null);
|
||||
setOverrideBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
|
||||
const trimmed = overrideInput.trim();
|
||||
if (!trimmed) throw new Error("enter_iso_timestamp");
|
||||
|
||||
const res = await fetch(
|
||||
`/api/assets/${viewer.asset.id}/override-capture-ts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ captureTsUtcOverride: trimmed }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`override_failed:${res.status}`);
|
||||
const json = (await res.json()) as OverrideResponse;
|
||||
setViewer((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
asset: {
|
||||
...prev.asset,
|
||||
capture_ts_utc:
|
||||
json.capture_ts_utc_override ?? json.base_capture_ts_utc,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setBaseCaptureTs(json.base_capture_ts_utc ?? null);
|
||||
setOverrideError("Override saved.");
|
||||
} catch (err) {
|
||||
setOverrideError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setOverrideBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearOverride() {
|
||||
if (!viewer) return;
|
||||
setOverrideError(null);
|
||||
setOverrideBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
const res = await fetch(
|
||||
`/api/assets/${viewer.asset.id}/override-capture-ts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ captureTsUtcOverride: null }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`override_clear_failed:${res.status}`);
|
||||
const json = (await res.json()) as OverrideResponse;
|
||||
setViewer((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
asset: {
|
||||
...prev.asset,
|
||||
capture_ts_utc:
|
||||
json.capture_ts_utc_override ?? json.base_capture_ts_utc,
|
||||
},
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setBaseCaptureTs(json.base_capture_ts_utc ?? null);
|
||||
setOverrideInput(json.base_capture_ts_utc ?? "");
|
||||
setOverrideError("Override cleared.");
|
||||
} catch (err) {
|
||||
setOverrideError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setOverrideBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminLists() {
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) {
|
||||
setAdminError("Set admin token on /admin first.");
|
||||
return;
|
||||
}
|
||||
const headers = { "X-Porthole-Admin-Token": token };
|
||||
const [tagsRes, albumsRes] = await Promise.all([
|
||||
fetch("/api/tags", { headers, cache: "no-store" }),
|
||||
fetch("/api/albums", { headers, cache: "no-store" }),
|
||||
]);
|
||||
if (!tagsRes.ok) throw new Error(`tags_fetch_failed:${tagsRes.status}`);
|
||||
if (!albumsRes.ok)
|
||||
throw new Error(`albums_fetch_failed:${albumsRes.status}`);
|
||||
const tagsJson = (await tagsRes.json()) as Tag[];
|
||||
const albumsJson = (await albumsRes.json()) as Album[];
|
||||
setTags(tagsJson);
|
||||
setAlbums(albumsJson);
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignTag() {
|
||||
if (!viewer) return;
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
if (!tagId) throw new Error("select_tag");
|
||||
const res = await fetch(`/api/assets/${viewer.asset.id}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tagId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`tag_assign_failed:${res.status}`);
|
||||
setAdminError("Tag assigned.");
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToAlbum() {
|
||||
if (!viewer) return;
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
if (!albumId) throw new Error("select_album");
|
||||
const res = await fetch(`/api/albums/${albumId}/assets`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assetId: viewer.asset.id }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`album_add_failed:${res.status}`);
|
||||
setAdminError("Added to album.");
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -377,6 +648,150 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
{viewer.asset.id}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #eee",
|
||||
paddingTop: 12,
|
||||
display: "grid",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Duplicates</strong>
|
||||
{dupesError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{dupesError}
|
||||
</div>
|
||||
) : null}
|
||||
{dupes === null ? (
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : dupes.length === 0 ? (
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
No duplicates.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: "#444",
|
||||
}}
|
||||
>
|
||||
<div>{dupes.length} duplicate(s)</div>
|
||||
{dupes.map((dupe) => (
|
||||
<div key={dupe.id}>{dupe.id.slice(0, 8)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #eee",
|
||||
paddingTop: 12,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Capture time override</strong>
|
||||
<div style={{ fontSize: 12, color: "#555" }}>
|
||||
Effective: {viewer.asset.capture_ts_utc ?? "(unset)"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#555" }}>
|
||||
Base: {baseCaptureTs ?? "(unknown)"}
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "#555" }}>
|
||||
ISO timestamp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="2026-02-01T12:34:56.000Z"
|
||||
value={overrideInput}
|
||||
onChange={(e) => setOverrideInput(e.target.value)}
|
||||
style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }}
|
||||
disabled={overrideBusy}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" onClick={handleOverrideCaptureTs}>
|
||||
Save override
|
||||
</button>
|
||||
<button type="button" onClick={handleClearOverride}>
|
||||
Clear override
|
||||
</button>
|
||||
</div>
|
||||
{overrideError ? (
|
||||
<div style={{ fontSize: 12, color: "#b00" }}>
|
||||
{overrideError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #eee",
|
||||
paddingTop: 12,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Tags & Albums</strong>
|
||||
{adminError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{adminError}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "#555" }}>
|
||||
Assign tag
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={tagId}
|
||||
onChange={(e) => setTagId(e.target.value)}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
disabled={adminBusy}
|
||||
>
|
||||
<option value="">Select tag</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleAssignTag}>
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "#555" }}>
|
||||
Add to album
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={albumId}
|
||||
onChange={(e) => setAlbumId(e.target.value)}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
disabled={adminBusy}
|
||||
>
|
||||
<option value="">Select album</option>
|
||||
{albums.map((album) => (
|
||||
<option key={album.id} value={album.id}>
|
||||
{album.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleAddToAlbum}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>
|
||||
|
||||
@@ -27,6 +27,17 @@ type ApiTreeResponse = {
|
||||
nodes: ApiTreeRow[];
|
||||
};
|
||||
|
||||
type MomentCluster = {
|
||||
day: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type MomentsResponse = {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
clusters: MomentCluster[];
|
||||
};
|
||||
|
||||
type Orientation = "vertical" | "horizontal";
|
||||
|
||||
type ExpandedState = Record<string, boolean>;
|
||||
@@ -147,6 +158,9 @@ export function TimelineTree(props: {
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showMoments, setShowMoments] = useState(false);
|
||||
const [moments, setMoments] = useState<MomentsResponse | null>(null);
|
||||
const [momentsError, setMomentsError] = useState<string | null>(null);
|
||||
|
||||
// simple pan/zoom via viewBox
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
@@ -182,6 +196,38 @@ export function TimelineTree(props: {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMoments || !rows) return;
|
||||
let cancelled = false;
|
||||
async function loadMoments() {
|
||||
try {
|
||||
setMomentsError(null);
|
||||
if (!rows || rows.length === 0) return;
|
||||
const start = rows[0]?.group_ts ?? null;
|
||||
const last = rows[rows.length - 1]?.group_ts ?? null;
|
||||
const end = last
|
||||
? new Date(new Date(last).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
const params = new URLSearchParams();
|
||||
if (start) params.set("start", start);
|
||||
if (end) params.set("end", end);
|
||||
params.set("includeFailed", "1");
|
||||
const res = await fetch(`/api/moments?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`moments_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as MomentsResponse;
|
||||
if (!cancelled) setMoments(json);
|
||||
} catch (e) {
|
||||
if (!cancelled) setMomentsError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
void loadMoments();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showMoments, rows]);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(
|
||||
() => gatherVisible(roots, expanded),
|
||||
@@ -315,12 +361,18 @@ export function TimelineTree(props: {
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowMoments((v) => !v)}>
|
||||
{showMoments ? "Hide moments" : "Show moments"}
|
||||
</button>
|
||||
{rows ? (
|
||||
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{momentsError ? (
|
||||
<div style={{ color: "#b00" }}>Moments error: {momentsError}</div>
|
||||
) : null}
|
||||
{!rows && !error ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -390,6 +442,15 @@ export function TimelineTree(props: {
|
||||
const isDay = node.id.startsWith("d:");
|
||||
const clickCursor = hasChildren || isDay ? "pointer" : "default";
|
||||
|
||||
const dayKey = node.label;
|
||||
const dayMoments = showMoments
|
||||
? moments?.clusters.filter((c) => c.day === dayKey) ?? []
|
||||
: [];
|
||||
const momentsCount = dayMoments.reduce(
|
||||
(sum, c) => sum + c.count,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
@@ -404,6 +465,7 @@ export function TimelineTree(props: {
|
||||
<text x={20} y={5} fontSize={14} fill="#111">
|
||||
{node.label} ({node.countReady}/{node.countTotal})
|
||||
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
|
||||
{showMoments && isDay ? ` · ${momentsCount} moment assets` : ""}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { getAppName } from "@tline/config";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
export const metadata = {
|
||||
title: getAppName()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
export type MomentAsset = {
|
||||
id: string;
|
||||
capture_ts_utc: string;
|
||||
};
|
||||
|
||||
export type MomentCluster = {
|
||||
day: string;
|
||||
start: string;
|
||||
end: string;
|
||||
count: number;
|
||||
assets: MomentAsset[];
|
||||
};
|
||||
|
||||
const MOMENT_WINDOW_MINUTES = 30;
|
||||
const MOMENT_WINDOW_MS = MOMENT_WINDOW_MINUTES * 60 * 1000;
|
||||
|
||||
function dayKeyFromIso(iso: string) {
|
||||
const d = new Date(iso);
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export function clusterMoments(input: MomentAsset[]): MomentCluster[] {
|
||||
const byDay = new Map<string, MomentAsset[]>();
|
||||
|
||||
for (const asset of input) {
|
||||
if (!asset.capture_ts_utc) continue;
|
||||
const key = dayKeyFromIso(asset.capture_ts_utc);
|
||||
const list = byDay.get(key);
|
||||
if (list) list.push(asset);
|
||||
else byDay.set(key, [asset]);
|
||||
}
|
||||
|
||||
const clusters: MomentCluster[] = [];
|
||||
|
||||
for (const [day, assets] of byDay) {
|
||||
const sorted = [...assets].sort((a, b) =>
|
||||
a.capture_ts_utc.localeCompare(b.capture_ts_utc),
|
||||
);
|
||||
|
||||
let current: MomentAsset[] = [];
|
||||
let lastTs: number | null = null;
|
||||
|
||||
for (const asset of sorted) {
|
||||
const ts = new Date(asset.capture_ts_utc).getTime();
|
||||
if (!Number.isFinite(ts)) continue;
|
||||
|
||||
if (lastTs === null || ts - lastTs <= MOMENT_WINDOW_MS) {
|
||||
current.push(asset);
|
||||
} else {
|
||||
const start = current[0]?.capture_ts_utc;
|
||||
const end = current[current.length - 1]?.capture_ts_utc;
|
||||
if (start && end) {
|
||||
clusters.push({
|
||||
day,
|
||||
start,
|
||||
end,
|
||||
count: current.length,
|
||||
assets: current,
|
||||
});
|
||||
}
|
||||
current = [asset];
|
||||
}
|
||||
|
||||
lastTs = ts;
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
const start = current[0].capture_ts_utc;
|
||||
const end = current[current.length - 1].capture_ts_utc;
|
||||
clusters.push({
|
||||
day,
|
||||
start,
|
||||
end,
|
||||
count: current.length,
|
||||
assets: current,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type Variant = {
|
||||
kind: "video_mp4";
|
||||
size: number;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type PlaybackInput = {
|
||||
originalMimeType: string | null | undefined;
|
||||
variants: Variant[];
|
||||
};
|
||||
|
||||
export function pickVideoPlaybackVariant(
|
||||
input: PlaybackInput,
|
||||
): { kind: "video_mp4"; size: number } | null {
|
||||
const mp4Variant = input.variants.find(
|
||||
(variant) => variant.kind === "video_mp4" && variant.size === 720,
|
||||
);
|
||||
if (mp4Variant) {
|
||||
return { kind: "video_mp4", size: mp4Variant.size };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import L from "leaflet";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
|
||||
type GeoPoint = {
|
||||
id: string;
|
||||
gps_lat: number | null;
|
||||
gps_lon: number | null;
|
||||
};
|
||||
|
||||
function MapContent({ points, error }: { points: GeoPoint[]; error: string | null }) {
|
||||
const map = useMap();
|
||||
const markersRef = useRef<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
markersRef.current.forEach((marker) => marker.remove());
|
||||
markersRef.current = [];
|
||||
|
||||
if (points.length === 0) return;
|
||||
|
||||
points.forEach((point) => {
|
||||
if (point.gps_lat === null || point.gps_lon === null) return;
|
||||
|
||||
const marker = L.marker([point.gps_lat, point.gps_lon]);
|
||||
marker.addTo(map);
|
||||
markersRef.current.push(marker);
|
||||
});
|
||||
|
||||
if (points.length > 0) {
|
||||
const group = L.featureGroup(markersRef.current);
|
||||
map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
}, [points, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapPage() {
|
||||
const [points, setPoints] = useState<GeoPoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/geo")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch geo points");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setPoints(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main style={{ padding: 16, display: "grid", gap: 16, height: "calc(100vh - 32px)" }}>
|
||||
<header>
|
||||
<h1 style={{ marginTop: 0 }}>Map</h1>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: 40 }}>
|
||||
Loading map...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#b00" }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
) : points.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: 40, color: "#666" }}>
|
||||
No GPS points available
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<MapContainer
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
boundsOptions={{ padding: [50, 50] }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapContent points={points} error={error} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export default function HomePage() {
|
||||
<header>
|
||||
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/map">Map</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/admin">Admin</Link>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("imports POST rejects when missing admin token", async () => {
|
||||
const { handleCreateImport } = await import("../../app/api/imports/handlers");
|
||||
const res = await handleCreateImport({ adminOk: false, body: {} });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("imports upload rejects when missing admin token", async () => {
|
||||
const { handleUploadImport } = await import("../../app/api/imports/handlers");
|
||||
const res = await handleUploadImport({
|
||||
adminOk: false,
|
||||
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||
request: new Request("http://localhost/upload"),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("imports scan rejects when missing admin token", async () => {
|
||||
const { handleScanMinioImport } = await import("../../app/api/imports/handlers");
|
||||
const res = await handleScanMinioImport({
|
||||
adminOk: false,
|
||||
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||
body: {},
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
function createMockDb(responses: Array<unknown>) {
|
||||
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
||||
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
calls.push({ sql: strings.join(""), values });
|
||||
const next = responses.shift();
|
||||
return next as T;
|
||||
};
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
test("albums POST rejects when missing admin token", async () => {
|
||||
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
|
||||
const res = await handleCreateAlbum({ adminOk: false, body: {} });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("albums GET rejects when missing admin token", async () => {
|
||||
const { handleListAlbums } = await import("../../app/api/albums/handlers");
|
||||
const res = await handleListAlbums({ adminOk: false });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("album add asset rejects when missing admin token", async () => {
|
||||
const { handleAddAlbumAsset } = await import(
|
||||
"../../app/api/albums/handlers"
|
||||
);
|
||||
const res = await handleAddAlbumAsset({
|
||||
adminOk: false,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { assetId: "00000000-0000-4000-8000-000000000000" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("album remove asset rejects when missing admin token", async () => {
|
||||
const { handleRemoveAlbumAsset } = await import(
|
||||
"../../app/api/albums/handlers"
|
||||
);
|
||||
const res = await handleRemoveAlbumAsset({
|
||||
adminOk: false,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { assetId: "00000000-0000-4000-8000-000000000000" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("albums GET returns rows", async () => {
|
||||
const { handleListAlbums } = await import("../../app/api/albums/handlers");
|
||||
const { db } = createMockDb([
|
||||
[
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000010",
|
||||
name: "Summer",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const res = await handleListAlbums({ adminOk: true, db: db as never });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000010",
|
||||
name: "Summer",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("albums POST inserts and writes audit log", async () => {
|
||||
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
|
||||
const { db, calls } = createMockDb([
|
||||
[
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000020",
|
||||
name: "Trips",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
const res = await handleCreateAlbum({
|
||||
adminOk: true,
|
||||
body: { name: "Trips" },
|
||||
db: db as never,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
id: "00000000-0000-4000-8000-000000000020",
|
||||
name: "Trips",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("albums POST rejects invalid body", async () => {
|
||||
const { handleCreateAlbum } = await import("../../app/api/albums/handlers");
|
||||
const res = await handleCreateAlbum({ adminOk: true, body: { name: "" } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
|
||||
});
|
||||
|
||||
test("album add asset inserts and writes audit log", async () => {
|
||||
const { handleAddAlbumAsset } = await import(
|
||||
"../../app/api/albums/handlers"
|
||||
);
|
||||
const { db, calls } = createMockDb([
|
||||
[
|
||||
{
|
||||
album_id: "00000000-0000-4000-8000-000000000030",
|
||||
asset_id: "00000000-0000-4000-8000-000000000031",
|
||||
ord: 2,
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
const res = await handleAddAlbumAsset({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000030" },
|
||||
body: { assetId: "00000000-0000-4000-8000-000000000031", ord: 2 },
|
||||
db: db as never,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
album_id: "00000000-0000-4000-8000-000000000030",
|
||||
asset_id: "00000000-0000-4000-8000-000000000031",
|
||||
ord: 2,
|
||||
});
|
||||
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("album remove asset deletes and writes audit log", async () => {
|
||||
const { handleRemoveAlbumAsset } = await import(
|
||||
"../../app/api/albums/handlers"
|
||||
);
|
||||
const { db, calls } = createMockDb([[], [], []]);
|
||||
const res = await handleRemoveAlbumAsset({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000040" },
|
||||
body: { assetId: "00000000-0000-4000-8000-000000000041" },
|
||||
db: db as never,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(calls.some((call) => call.sql.includes("delete from album_assets"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import type { getDb } from "@tline/db";
|
||||
|
||||
type DbRow = {
|
||||
asset_id: string;
|
||||
capture_ts_utc_override: string | null;
|
||||
capture_offset_minutes_override: number | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const createDbStub = (initial: DbRow) => {
|
||||
let current = { ...initial };
|
||||
|
||||
const db = async <T>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<T> => {
|
||||
const query = strings.join("");
|
||||
if (query.includes("insert into asset_overrides")) {
|
||||
const [assetId, captureTs, captureOffset, tsProvided, offsetProvided] =
|
||||
values;
|
||||
const hasFlags =
|
||||
typeof tsProvided === "boolean" && typeof offsetProvided === "boolean";
|
||||
const updateTs = hasFlags ? (tsProvided as boolean) : true;
|
||||
const updateOffset = hasFlags ? (offsetProvided as boolean) : true;
|
||||
|
||||
if (updateTs) {
|
||||
if (captureTs instanceof Date) {
|
||||
current.capture_ts_utc_override = captureTs.toISOString();
|
||||
} else if (captureTs === null) {
|
||||
current.capture_ts_utc_override = null;
|
||||
} else {
|
||||
current.capture_ts_utc_override = String(captureTs ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
if (updateOffset) {
|
||||
current.capture_offset_minutes_override =
|
||||
captureOffset as number | null;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
asset_id: String(assetId),
|
||||
capture_ts_utc_override: current.capture_ts_utc_override,
|
||||
capture_offset_minutes_override: current.capture_offset_minutes_override,
|
||||
created_at: current.created_at,
|
||||
},
|
||||
] as T;
|
||||
}
|
||||
|
||||
if (query.includes("insert into audit_log")) {
|
||||
return [] as T;
|
||||
}
|
||||
|
||||
if (query.includes("select capture_ts_utc") && query.includes("from assets")) {
|
||||
return [{ capture_ts_utc: current.capture_ts_utc_override }] as T;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query: ${query}`);
|
||||
};
|
||||
|
||||
return db as unknown as ReturnType<typeof getDb>;
|
||||
};
|
||||
|
||||
test("asset overrides POST rejects when missing admin token", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: false,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("asset overrides POST rejects invalid body", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { captureTsUtcOverride: "not-a-date" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
|
||||
});
|
||||
|
||||
test("asset overrides POST rejects unknown fields", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: {
|
||||
captureTsUtcOverride: "2026-02-01T00:00:00.000Z",
|
||||
extra: "nope",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
});
|
||||
|
||||
test("asset overrides POST rejects string offset", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: {
|
||||
captureOffsetMinutesOverride: "15",
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
});
|
||||
|
||||
test("asset overrides POST rejects empty body", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: {},
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
});
|
||||
|
||||
test("asset overrides POST preserves omitted fields", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const db = createDbStub({
|
||||
asset_id: "00000000-0000-4000-8000-000000000000",
|
||||
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
|
||||
capture_offset_minutes_override: 90,
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { captureTsUtcOverride: "2026-02-01T00:00:00.000Z" },
|
||||
db,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
capture_ts_utc_override: "2026-02-01T00:00:00.000Z",
|
||||
capture_offset_minutes_override: 90,
|
||||
});
|
||||
});
|
||||
|
||||
test("asset overrides POST allows explicit null clearing", async () => {
|
||||
const { handleSetCaptureOverride } = await import(
|
||||
"../../app/api/assets/[id]/override-capture-ts/handlers"
|
||||
);
|
||||
const db = createDbStub({
|
||||
asset_id: "00000000-0000-4000-8000-000000000000",
|
||||
capture_ts_utc_override: "2026-01-01T00:00:00.000Z",
|
||||
capture_offset_minutes_override: 90,
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
const res = await handleSetCaptureOverride({
|
||||
adminOk: true,
|
||||
params: { id: "00000000-0000-4000-8000-000000000000" },
|
||||
body: { captureOffsetMinutesOverride: null },
|
||||
db,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
capture_offset_minutes_override: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { handleGetDupes } from "../../app/api/assets/[id]/dupes/handlers";
|
||||
|
||||
describe("handleGetDupes", () => {
|
||||
test("returns empty list when hash is missing", async () => {
|
||||
let call = 0;
|
||||
const db = async () => {
|
||||
call += 1;
|
||||
return [] as unknown[];
|
||||
};
|
||||
|
||||
const result = await handleGetDupes({
|
||||
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||
db,
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ items: [] });
|
||||
expect(call).toBe(1);
|
||||
});
|
||||
|
||||
test("returns dupes excluding the asset id", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const db = async () => {
|
||||
calls.push(true);
|
||||
if (calls.length === 1) {
|
||||
return [{ bucket: "photos", sha256: "hash" }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
media_type: "image",
|
||||
status: "ready",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const result = await handleGetDupes({
|
||||
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||
db,
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
media_type: "image",
|
||||
status: "ready",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("shapeGeoRows returns id/lat/lon only", async () => {
|
||||
const { shapeGeoRows } = await import("../../app/api/geo/shape");
|
||||
const rows = [
|
||||
{
|
||||
id: "a",
|
||||
gps_lat: 40.1,
|
||||
gps_lon: -73.9,
|
||||
capture_ts_utc: "2026-02-01T00:00:00.000Z",
|
||||
media_type: "image",
|
||||
},
|
||||
];
|
||||
expect(shapeGeoRows(rows)).toEqual([
|
||||
{ id: "a", gps_lat: 40.1, gps_lon: -73.9 },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
import { clusterMoments } from "../../app/lib/moments";
|
||||
|
||||
test("clusterMoments groups assets within 30 minutes", () => {
|
||||
const clusters = clusterMoments([
|
||||
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
|
||||
{ id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" },
|
||||
{ id: "c", capture_ts_utc: "2026-02-01T10:49:00.000Z" },
|
||||
]);
|
||||
|
||||
expect(clusters).toHaveLength(1);
|
||||
expect(clusters[0]?.count).toBe(3);
|
||||
});
|
||||
|
||||
test("clusterMoments splits after window gap", () => {
|
||||
const clusters = clusterMoments([
|
||||
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
|
||||
{ id: "b", capture_ts_utc: "2026-02-01T11:05:00.000Z" },
|
||||
]);
|
||||
|
||||
expect(clusters).toHaveLength(2);
|
||||
expect(clusters[0]?.count).toBe(1);
|
||||
expect(clusters[1]?.count).toBe(1);
|
||||
});
|
||||
|
||||
test("clusterMoments sorts inputs per day", () => {
|
||||
const clusters = clusterMoments([
|
||||
{ id: "b", capture_ts_utc: "2026-02-01T10:20:00.000Z" },
|
||||
{ id: "a", capture_ts_utc: "2026-02-01T10:00:00.000Z" },
|
||||
{ id: "c", capture_ts_utc: "2026-02-01T10:40:00.000Z" },
|
||||
]);
|
||||
|
||||
expect(clusters).toHaveLength(1);
|
||||
expect(clusters[0]?.assets.map((a) => a.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("clusterMoments splits by day", () => {
|
||||
const clusters = clusterMoments([
|
||||
{ id: "a", capture_ts_utc: "2026-02-01T23:50:00.000Z" },
|
||||
{ id: "b", capture_ts_utc: "2026-02-02T00:10:00.000Z" },
|
||||
]);
|
||||
|
||||
expect(clusters).toHaveLength(2);
|
||||
expect(clusters[0]?.day).toBe("2026-02-01");
|
||||
expect(clusters[1]?.day).toBe("2026-02-02");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
|
||||
|
||||
test("prefer mp4 derived over original", () => {
|
||||
const picked = pickVideoPlaybackVariant({
|
||||
originalMimeType: "video/x-matroska",
|
||||
variants: [{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }],
|
||||
});
|
||||
expect(picked).toEqual({ kind: "video_mp4", size: 720 });
|
||||
});
|
||||
|
||||
test("returns null when no mp4 variants", () => {
|
||||
const picked = pickVideoPlaybackVariant({
|
||||
originalMimeType: "video/x-matroska",
|
||||
variants: [],
|
||||
});
|
||||
expect(picked).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("bun test runs", () => expect(1 + 1).toBe(2));
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
function createMockDb(responses: Array<unknown>) {
|
||||
const calls: Array<{ sql: string; values: unknown[] }> = [];
|
||||
const db = async <T>(strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
calls.push({ sql: strings.join(""), values });
|
||||
const next = responses.shift();
|
||||
return next as T;
|
||||
};
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
test("tags POST rejects when missing admin token", async () => {
|
||||
const { handleCreateTag } = await import("../../app/api/tags/handlers");
|
||||
const res = await handleCreateTag({ adminOk: false, body: {} });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("tags GET rejects when missing admin token", async () => {
|
||||
const { handleListTags } = await import("../../app/api/tags/handlers");
|
||||
const res = await handleListTags({ adminOk: false });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "admin_required" });
|
||||
});
|
||||
|
||||
test("tags GET returns rows", async () => {
|
||||
const { handleListTags } = await import("../../app/api/tags/handlers");
|
||||
const { db } = createMockDb([
|
||||
[
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "Pets",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const res = await handleListTags({ adminOk: true, db: db as never });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "Pets",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("tags POST inserts and writes audit log", async () => {
|
||||
const { handleCreateTag } = await import("../../app/api/tags/handlers");
|
||||
const { db, calls } = createMockDb([
|
||||
[
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000002",
|
||||
name: "Trips",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
const res = await handleCreateTag({
|
||||
adminOk: true,
|
||||
body: { name: "Trips" },
|
||||
db: db as never,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
id: "00000000-0000-4000-8000-000000000002",
|
||||
name: "Trips",
|
||||
created_at: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
expect(calls.some((call) => call.sql.includes("insert into audit_log"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("tags POST rejects invalid body", async () => {
|
||||
const { handleCreateTag } = await import("../../app/api/tags/handlers");
|
||||
const res = await handleCreateTag({ adminOk: true, body: { name: "" } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({ error: "invalid_body" });
|
||||
expect(Array.isArray((res.body as { issues?: unknown }).issues)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("variant lookup returns null when no matching variant", async () => {
|
||||
const { pickVariantKey } = await import(
|
||||
"../../app/api/assets/[id]/url/variant",
|
||||
);
|
||||
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
|
||||
expect(key).toBeNull();
|
||||
});
|
||||
|
||||
test("legacy fallback maps kind+size to asset keys", async () => {
|
||||
const { pickLegacyKeyForRequest } = await import(
|
||||
"../../app/api/assets/[id]/url/variant",
|
||||
);
|
||||
const asset = {
|
||||
thumb_small_key: "thumb-small",
|
||||
thumb_med_key: "thumb-med",
|
||||
poster_key: "poster",
|
||||
};
|
||||
|
||||
expect(
|
||||
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 256 }),
|
||||
).toBe("thumb-small");
|
||||
expect(
|
||||
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 768 }),
|
||||
).toBe("thumb-med");
|
||||
expect(
|
||||
pickLegacyKeyForRequest({ asset }, { kind: "poster", size: 256 }),
|
||||
).toBe("poster");
|
||||
expect(
|
||||
pickLegacyKeyForRequest({ asset }, { kind: "thumb", size: 1024 }),
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("variants route returns only kind/size/key fields", async () => {
|
||||
const { shapeVariants } = await import(
|
||||
"../../app/api/assets/[id]/variants/shape",
|
||||
);
|
||||
const rows = [
|
||||
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4", mime_type: "video/mp4" },
|
||||
];
|
||||
expect(shapeVariants(rows)).toEqual([
|
||||
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" },
|
||||
]);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { shouldTranscodeToMp4 } from "../transcode";
|
||||
|
||||
test("transcode runs for non-mp4 videos", () => {
|
||||
expect(shouldTranscodeToMp4({ mimeType: "video/x-matroska" })).toBe(true);
|
||||
});
|
||||
|
||||
test("transcode skips for mp4", () => {
|
||||
expect(shouldTranscodeToMp4({ mimeType: "video/mp4" })).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { computeImageVariantPlan, pickSmallestVariantSize } from "../variants";
|
||||
|
||||
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
|
||||
expect(computeImageVariantPlan()).toEqual([
|
||||
{ kind: "thumb", size: 256 },
|
||||
{ kind: "thumb", size: 768 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("pickSmallestVariantSize returns smallest poster size", () => {
|
||||
const size = pickSmallestVariantSize([
|
||||
{ kind: "poster", size: 768 },
|
||||
{ kind: "poster", size: 256 },
|
||||
]);
|
||||
expect(size).toBe(256);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
export async function computeFileSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
return await new Promise((resolve, reject) => {
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { closeDb } from "@tline/db";
|
||||
import {
|
||||
handleCopyToCanonical,
|
||||
handleProcessAsset,
|
||||
handleScanMinioPrefix
|
||||
handleScanMinioPrefix,
|
||||
handleTranscodeVideoMp4
|
||||
} from "./jobs";
|
||||
|
||||
console.log(`[${getAppName()}] worker boot`);
|
||||
@@ -30,6 +31,7 @@ const worker = new Worker(
|
||||
if (job.name === "scan_minio_prefix") return handleScanMinioPrefix(job.data);
|
||||
if (job.name === "process_asset") return handleProcessAsset(job.data);
|
||||
if (job.name === "copy_to_canonical") return handleCopyToCanonical(job.data);
|
||||
if (job.name === "transcode_video_mp4") return handleTranscodeVideoMp4(job.data);
|
||||
|
||||
throw new Error(`Unknown job: ${job.name}`);
|
||||
},
|
||||
|
||||
+330
-54
@@ -7,6 +7,12 @@ import { Readable } from "stream";
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
import {
|
||||
computeImageVariantPlan,
|
||||
computeVideoPosterPlan,
|
||||
pickSmallestVariantSize,
|
||||
} from "./variants";
|
||||
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
GetObjectCommand,
|
||||
@@ -21,10 +27,15 @@ import {
|
||||
copyToCanonicalPayloadSchema,
|
||||
enqueueCopyToCanonical,
|
||||
enqueueProcessAsset,
|
||||
enqueueTranscodeVideoMp4,
|
||||
processAssetPayloadSchema,
|
||||
scanMinioPrefixPayloadSchema,
|
||||
transcodeVideoMp4PayloadSchema,
|
||||
} from "@tline/queue";
|
||||
|
||||
import { shouldTranscodeToMp4 } from "./transcode";
|
||||
import { computeFileSha256 } from "./hash-utils";
|
||||
|
||||
const allowedScanPrefixes = ["originals/"] as const;
|
||||
|
||||
function assertAllowedScanPrefix(prefix: string) {
|
||||
@@ -205,6 +216,45 @@ async function uploadObject(input: {
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertVariant(input: {
|
||||
assetId: string;
|
||||
kind: "thumb" | "poster" | "video_mp4";
|
||||
size: number;
|
||||
key: string;
|
||||
mimeType: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}) {
|
||||
const db = getDb();
|
||||
await db`
|
||||
insert into asset_variants (asset_id, kind, size, key, mime_type, width, height)
|
||||
values (
|
||||
${input.assetId},
|
||||
${input.kind},
|
||||
${input.size},
|
||||
${input.key},
|
||||
${input.mimeType},
|
||||
${input.width ?? null},
|
||||
${input.height ?? null}
|
||||
)
|
||||
on conflict (asset_id, kind, size)
|
||||
do update set key = excluded.key,
|
||||
mime_type = excluded.mime_type,
|
||||
width = excluded.width,
|
||||
height = excluded.height
|
||||
`;
|
||||
}
|
||||
|
||||
async function upsertAssetHash(input: { assetId: string; bucket: string; sha256: string }) {
|
||||
const db = getDb();
|
||||
await db`
|
||||
insert into asset_hashes (asset_id, bucket, sha256)
|
||||
values (${input.assetId}, ${input.bucket}, ${input.sha256})
|
||||
on conflict (asset_id)
|
||||
do update set sha256 = excluded.sha256, bucket = excluded.bucket
|
||||
`;
|
||||
}
|
||||
|
||||
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
|
||||
const s3 = getMinioInternalClient();
|
||||
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
|
||||
@@ -232,6 +282,105 @@ function parseExifDate(dateStr: string | undefined): Date | null {
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function parseGpsParts(parts: number[]): number | null {
|
||||
if (parts.length === 0 || !Number.isFinite(parts[0])) return null;
|
||||
const [deg, min, sec] = parts;
|
||||
const sign = deg < 0 ? -1 : 1;
|
||||
let value = Math.abs(deg);
|
||||
if (Number.isFinite(min)) value += Math.abs(min) / 60;
|
||||
if (Number.isFinite(sec)) value += Math.abs(sec) / 3600;
|
||||
return sign * value;
|
||||
}
|
||||
|
||||
function parseGpsFraction(input: string): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const numerator = Number(match[1]);
|
||||
const denominator = Number(match[2]);
|
||||
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null;
|
||||
if (denominator === 0) return null;
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
function parseGpsValue(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const direct = Number(trimmed);
|
||||
if (!Number.isNaN(direct)) return direct;
|
||||
const fraction = parseGpsFraction(trimmed);
|
||||
if (fraction !== null) return fraction;
|
||||
const parts = trimmed.match(/-?\d+(?:\.\d+)?/g);
|
||||
if (!parts) return null;
|
||||
return parseGpsParts(parts.map((part) => Number(part)).filter(Number.isFinite));
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((part) => {
|
||||
if (typeof part === "number") return part;
|
||||
if (typeof part === "string") {
|
||||
const fraction = parseGpsFraction(part);
|
||||
if (fraction !== null) return fraction;
|
||||
return Number(part);
|
||||
}
|
||||
if (typeof part === "object" && part !== null) {
|
||||
const candidate = part as Record<string, unknown>;
|
||||
const numerator = Number(candidate.numerator);
|
||||
const denominator = Number(candidate.denominator);
|
||||
if (Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0) {
|
||||
return numerator / denominator;
|
||||
}
|
||||
}
|
||||
return NaN;
|
||||
})
|
||||
.filter(Number.isFinite);
|
||||
return parseGpsParts(parts);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRefSign(value: number, ref: unknown, valueRaw: unknown): number {
|
||||
const refChar = typeof ref === "string" ? ref.trim().toUpperCase() : "";
|
||||
const rawChar =
|
||||
typeof valueRaw === "string"
|
||||
? (valueRaw.trim().match(/[NSEW]/i)?.[0]?.toUpperCase() ?? "")
|
||||
: "";
|
||||
const normalized = refChar || rawChar;
|
||||
if (normalized === "S" || normalized === "W") return -Math.abs(value);
|
||||
if (normalized === "N" || normalized === "E") return Math.abs(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGpsCoord(
|
||||
value: unknown,
|
||||
ref: unknown,
|
||||
kind: "lat" | "lon",
|
||||
): number | null {
|
||||
const parsed = parseGpsValue(value);
|
||||
if (parsed === null) return null;
|
||||
const signed = applyRefSign(parsed, ref, value);
|
||||
if (!Number.isFinite(signed)) return null;
|
||||
if (kind === "lat") {
|
||||
return signed >= -90 && signed <= 90 ? signed : null;
|
||||
}
|
||||
return signed >= -180 && signed <= 180 ? signed : null;
|
||||
}
|
||||
|
||||
function extractGps(tags: Record<string, unknown>) {
|
||||
const lat = parseGpsCoord(tags.GPSLatitude, tags.GPSLatitudeRef, "lat");
|
||||
const lon = parseGpsCoord(tags.GPSLongitude, tags.GPSLongitudeRef, "lon");
|
||||
if (lat === null || lon === null) return null;
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
function isPlausibleCaptureTs(date: Date) {
|
||||
const ts = date.getTime();
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
@@ -299,10 +448,13 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
Key: asset.active_key,
|
||||
}),
|
||||
);
|
||||
if (!getRes.Body) throw new Error("Empty response body from S3");
|
||||
await streamToFile(getRes.Body as Readable, inputPath);
|
||||
if (!getRes.Body) throw new Error("Empty response body from S3");
|
||||
await streamToFile(getRes.Body as Readable, inputPath);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
const sha256 = await computeFileSha256(inputPath);
|
||||
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
capture_ts_utc: null,
|
||||
date_confidence: null,
|
||||
width: null,
|
||||
@@ -312,7 +464,9 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
thumb_small_key: null,
|
||||
thumb_med_key: null,
|
||||
poster_key: null,
|
||||
raw_tags_json: null
|
||||
raw_tags_json: null,
|
||||
gps_lat: null,
|
||||
gps_lon: null
|
||||
};
|
||||
let rawTags: Record<string, unknown> = {};
|
||||
let captureTs: Date | null = null;
|
||||
@@ -386,6 +540,11 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
if (asset.media_type === "image") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
const gps = extractGps(rawTags);
|
||||
if (gps) {
|
||||
updates.gps_lat = gps.lat;
|
||||
updates.gps_lon = gps.lon;
|
||||
}
|
||||
await applyObjectMtimeFallback();
|
||||
|
||||
|
||||
@@ -397,38 +556,45 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
if (updates.width === null && imgMeta.width) updates.width = imgMeta.width;
|
||||
if (updates.height === null && imgMeta.height) updates.height = imgMeta.height;
|
||||
|
||||
const thumb256Path = join(tempDir, "thumb_256.jpg");
|
||||
const thumb768Path = join(tempDir, "thumb_768.jpg");
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb256Path);
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(768, 768, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumb768Path);
|
||||
const imagePlan = computeImageVariantPlan();
|
||||
const thumbKeys: Record<number, string> = {};
|
||||
for (const item of imagePlan) {
|
||||
const size = item.size;
|
||||
const thumbPath = join(tempDir, `thumb_${size}.jpg`);
|
||||
await sharp(inputPath)
|
||||
.rotate()
|
||||
.resize(size, size, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(thumbPath);
|
||||
|
||||
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`;
|
||||
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb256Key,
|
||||
filePath: thumb256Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumb768Key,
|
||||
filePath: thumb768Path,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.thumb_small_key = thumb256Key;
|
||||
updates.thumb_med_key = thumb768Key;
|
||||
const thumbKey = `thumbs/${asset.id}/image_${size}.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: thumbKey,
|
||||
filePath: thumbPath,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
await upsertVariant({
|
||||
assetId: asset.id,
|
||||
kind: "thumb",
|
||||
size,
|
||||
key: thumbKey,
|
||||
mimeType: "image/jpeg",
|
||||
width: typeof updates.width === "number" ? updates.width : null,
|
||||
height: typeof updates.height === "number" ? updates.height : null,
|
||||
});
|
||||
thumbKeys[size] = thumbKey;
|
||||
}
|
||||
updates.thumb_small_key = thumbKeys[256] ?? null;
|
||||
updates.thumb_med_key = thumbKeys[768] ?? null;
|
||||
} else if (asset.media_type === "video") {
|
||||
rawTags = await tryReadExifTags();
|
||||
maybeSetCaptureDateFromTags(rawTags);
|
||||
const gps = extractGps(rawTags);
|
||||
if (gps) {
|
||||
updates.gps_lat = gps.lat;
|
||||
updates.gps_lon = gps.lon;
|
||||
}
|
||||
|
||||
const ffprobeOutput = await runCommand("ffprobe", [
|
||||
"-v",
|
||||
@@ -465,27 +631,43 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
|
||||
rawTags = { ...rawTags, ffprobe: ffprobeData };
|
||||
|
||||
const posterPath = join(tempDir, "poster_256.jpg");
|
||||
await runCommand("ffmpeg", [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
"scale=256:256:force_original_aspect_ratio=decrease",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
posterPath
|
||||
]);
|
||||
const posterKey = `thumbs/${asset.id}/poster_256.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: posterKey,
|
||||
filePath: posterPath,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
updates.poster_key = posterKey;
|
||||
const posterPlan = computeVideoPosterPlan();
|
||||
const posterSmallest = pickSmallestVariantSize(posterPlan);
|
||||
const posterKeys: Record<number, string> = {};
|
||||
for (const item of posterPlan) {
|
||||
const size = item.size;
|
||||
const posterPath = join(tempDir, `poster_${size}.jpg`);
|
||||
await runCommand("ffmpeg", [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
`scale=${size}:${size}:force_original_aspect_ratio=decrease`,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
posterPath
|
||||
]);
|
||||
const posterKey = `thumbs/${asset.id}/poster_${size}.jpg`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: posterKey,
|
||||
filePath: posterPath,
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
await upsertVariant({
|
||||
assetId: asset.id,
|
||||
kind: "poster",
|
||||
size,
|
||||
key: posterKey,
|
||||
mimeType: "image/jpeg",
|
||||
width: typeof updates.width === "number" ? updates.width : null,
|
||||
height: typeof updates.height === "number" ? updates.height : null,
|
||||
});
|
||||
posterKeys[size] = posterKey;
|
||||
}
|
||||
updates.poster_key = posterSmallest ? posterKeys[posterSmallest] ?? null : null;
|
||||
}
|
||||
|
||||
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
|
||||
@@ -526,11 +708,17 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
"thumb_small_key",
|
||||
"thumb_med_key",
|
||||
"poster_key",
|
||||
"raw_tags_json"
|
||||
"raw_tags_json",
|
||||
"gps_lat",
|
||||
"gps_lon"
|
||||
)}, status = 'ready', error_message = null
|
||||
where id = ${asset.id}
|
||||
`;
|
||||
|
||||
if (asset.media_type === "video" && shouldTranscodeToMp4({ mimeType: asset.mime_type })) {
|
||||
await enqueueTranscodeVideoMp4({ assetId: asset.id });
|
||||
}
|
||||
|
||||
// Only uploads (staging/*) are copied into canonical by default.
|
||||
if (asset.active_key.startsWith("staging/")) {
|
||||
await enqueueCopyToCanonical({ assetId: asset.id });
|
||||
@@ -553,6 +741,94 @@ export async function handleProcessAsset(raw: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTranscodeVideoMp4(raw: unknown) {
|
||||
const payload = transcodeVideoMp4PayloadSchema.parse(raw);
|
||||
const db = getDb();
|
||||
const s3 = getMinioInternalClient();
|
||||
|
||||
const [asset] = await db<
|
||||
{
|
||||
id: string;
|
||||
bucket: string;
|
||||
active_key: string;
|
||||
mime_type: string;
|
||||
}[]
|
||||
>`
|
||||
select id, bucket, active_key, mime_type
|
||||
from assets
|
||||
where id = ${payload.assetId}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
|
||||
|
||||
if (!shouldTranscodeToMp4({ mimeType: asset.mime_type })) {
|
||||
return { ok: true, assetId: asset.id, skipped: "already_mp4" };
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "tline-transcode-"));
|
||||
|
||||
try {
|
||||
const containerExt = asset.mime_type.split("/")[1] ?? "bin";
|
||||
const inputPath = join(tempDir, `input.${containerExt}`);
|
||||
const getRes = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.bucket,
|
||||
Key: asset.active_key,
|
||||
}),
|
||||
);
|
||||
if (!getRes.Body) throw new Error("Empty response body from S3");
|
||||
await streamToFile(getRes.Body as Readable, inputPath);
|
||||
|
||||
const sha256 = await computeFileSha256(inputPath);
|
||||
await upsertAssetHash({ assetId: asset.id, bucket: asset.bucket, sha256 });
|
||||
|
||||
const outputPath = join(tempDir, "mp4_720p.mp4");
|
||||
await runCommand("ffmpeg", [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
"scale=-2:720",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-y",
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
const derivedKey = `derived/video/${asset.id}/mp4_720p.mp4`;
|
||||
await uploadObject({
|
||||
bucket: asset.bucket,
|
||||
key: derivedKey,
|
||||
filePath: outputPath,
|
||||
contentType: "video/mp4",
|
||||
});
|
||||
|
||||
await upsertVariant({
|
||||
assetId: asset.id,
|
||||
kind: "video_mp4",
|
||||
size: 720,
|
||||
key: derivedKey,
|
||||
mimeType: "video/mp4",
|
||||
width: null,
|
||||
height: 720,
|
||||
});
|
||||
|
||||
return { ok: true, assetId: asset.id, key: derivedKey };
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCopyToCanonical(raw: unknown) {
|
||||
const payload = copyToCanonicalPayloadSchema.parse(raw);
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldTranscodeToMp4(input: { mimeType: string }) {
|
||||
return input.mimeType !== "video/mp4";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type VariantPlanItem = {
|
||||
kind: "thumb" | "poster";
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function pickSmallestVariantSize(plan: VariantPlanItem[]): number | null {
|
||||
if (plan.length === 0) return null;
|
||||
return plan.reduce((min, item) => (item.size < min ? item.size : min), plan[0].size);
|
||||
}
|
||||
|
||||
export function computeImageVariantPlan(): VariantPlanItem[] {
|
||||
return [
|
||||
{ kind: "thumb", size: 256 },
|
||||
{ kind: "thumb", size: 768 },
|
||||
];
|
||||
}
|
||||
|
||||
export function computeVideoPosterPlan(): VariantPlanItem[] {
|
||||
return [
|
||||
{ kind: "poster", size: 256 },
|
||||
{ kind: "poster", size: 768 },
|
||||
];
|
||||
}
|
||||
@@ -13,7 +13,7 @@ spec:
|
||||
releaseName: porthole
|
||||
valueFiles:
|
||||
- values.yaml
|
||||
- ../../argocd/values-porthole.yaml
|
||||
- values-cluster.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: porthole
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
# Cluster-specific values for porthole deployment
|
||||
# TODO: Update these values before deployment
|
||||
|
||||
global:
|
||||
tailscale:
|
||||
tailnetFQDN: "taildb3494.ts.net" # Your tailnet FQDN
|
||||
tailnetFQDN: "taildb3494.ts.net"
|
||||
|
||||
secrets:
|
||||
postgres:
|
||||
password: "eIXuJEnRWuAs8AafqU3CWJ7Y4CgNAFQ" # From existing secret
|
||||
password: "eIXuJEnRWuAs8AafqU3CWJ7Y4CgNAFQ"
|
||||
minio:
|
||||
accessKeyId: "FwGiBwCXKuQdthR1QLa"
|
||||
secretAccessKey: "OtmAz9m7o941wG1Gms2yItyqxd6gCWY8k4LJVBX"
|
||||
|
||||
images:
|
||||
web:
|
||||
repository: gitea-gitea-http.taildb3494.ts.net/will/porthole-web
|
||||
tag: dev
|
||||
worker:
|
||||
repository: gitea-gitea-http.taildb3494.ts.net/will/porthole-worker
|
||||
tag: dev
|
||||
# Temporarily disable apps until registry is resolved
|
||||
# TODO: Set up proper container registry or use external registry
|
||||
web:
|
||||
enabled: false
|
||||
worker:
|
||||
enabled: false
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"": {
|
||||
"name": "tline",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"zod": "^4.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -272,6 +275,8 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
|
||||
|
||||
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
|
||||
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
|
||||
@@ -390,8 +395,12 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
@@ -518,6 +527,8 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -576,6 +587,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
||||
|
||||
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
bubbletea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/collectors/host"
|
||||
collectorsk8s "tower/internal/collectors/k8s"
|
||||
"tower/internal/engine"
|
||||
"tower/internal/export"
|
||||
"tower/internal/model"
|
||||
"tower/internal/store"
|
||||
"tower/internal/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRefreshInterval = 1 * time.Second
|
||||
defaultResolveAfter = 30 * time.Second
|
||||
collectorTimeoutFast = 250 * time.Millisecond
|
||||
collectorTimeoutK8sList = 2 * time.Second
|
||||
k8sUnreachableGraceDefault = 10 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
var exportPath string
|
||||
flag.StringVar(&exportPath, "export", "", "write issues JSON snapshot to this path and exit")
|
||||
flag.Parse()
|
||||
|
||||
if exportPath != "" {
|
||||
if err := validateExportPath(exportPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
st := store.New(defaultResolveAfter)
|
||||
|
||||
configs := []engine.CollectorConfig{
|
||||
{Collector: host.NewDiskCollector(), Timeout: collectorTimeoutFast},
|
||||
{Collector: host.NewMemCollector(), Timeout: collectorTimeoutFast},
|
||||
{Collector: host.NewLoadCollector(), Timeout: collectorTimeoutFast},
|
||||
{Collector: host.NewNetCollector(), Timeout: collectorTimeoutFast},
|
||||
}
|
||||
|
||||
// If kubeconfig is present, register the full Kubernetes collector (informers
|
||||
// with polling fallback, rules, rollups, and unreachable grace).
|
||||
if kubeconfigExists() {
|
||||
configs = append(configs, engine.CollectorConfig{Collector: collectorsk8s.NewCollector(), Timeout: collectorTimeoutK8sList})
|
||||
}
|
||||
|
||||
eng := engine.New(st, configs, defaultRefreshInterval)
|
||||
eng.Start(ctx)
|
||||
defer eng.Stop()
|
||||
|
||||
if exportPath != "" {
|
||||
// Give collectors a brief moment to run their initial collection.
|
||||
select {
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
snap := st.Snapshot(time.Now())
|
||||
if err := export.WriteIssues(exportPath, snap); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Run Bubble Tea UI.
|
||||
m := ui.New("", eng.Snapshots(), eng.RefreshNow, st.Acknowledge, st.Unacknowledge, export.WriteIssues)
|
||||
p := bubbletea.NewProgram(m, bubbletea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func kubeconfigExists() bool {
|
||||
// Respect KUBECONFIG when set; otherwise check ~/.kube/config.
|
||||
if p := os.Getenv("KUBECONFIG"); p != "" {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
p := filepath.Join(h, ".kube", "config")
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateExportPath(path string) error {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
if strings.Contains(cleanPath, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("path traversal not allowed in export path: %s", path)
|
||||
}
|
||||
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
return fmt.Errorf("absolute paths not allowed in export path: %s", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// k8sConnectivityCollector is a minimal Kubernetes collector.
|
||||
// It only validates connectivity/auth and emits a P0 issue after a grace window.
|
||||
//
|
||||
// Full cluster state collection is implemented elsewhere; this keeps main wired
|
||||
// and provides a useful health signal the UI can display.
|
||||
//
|
||||
// NOTE: This collector intentionally returns nil error on connectivity issues so
|
||||
// the Engine does not "freeze" last-known issues.
|
||||
//
|
||||
// It does not use informers (cheap) and runs at a low cadence.
|
||||
//
|
||||
//nolint:unused // referenced via newK8sConnectivityCollector
|
||||
type unreachableTracker struct {
|
||||
grace time.Duration
|
||||
firstFailureAt time.Time
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func newUnreachableTracker(grace time.Duration) *unreachableTracker {
|
||||
if grace <= 0 {
|
||||
grace = 10 * time.Second
|
||||
}
|
||||
return &unreachableTracker{grace: grace}
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) observeSuccess() {
|
||||
t.firstFailureAt = time.Time{}
|
||||
t.lastErr = nil
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) observeFailure(now time.Time, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
t.lastErr = err
|
||||
if t.firstFailureAt.IsZero() {
|
||||
t.firstFailureAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) shouldEmit(now time.Time) bool {
|
||||
return t.lastErr != nil && !t.firstFailureAt.IsZero() && now.Sub(t.firstFailureAt) >= t.grace
|
||||
}
|
||||
|
||||
type k8sConnectivityCollector struct {
|
||||
tracker *unreachableTracker
|
||||
}
|
||||
|
||||
func newK8sConnectivityCollector() collectors.Collector {
|
||||
return &k8sConnectivityCollector{tracker: newUnreachableTracker(k8sUnreachableGraceDefault)}
|
||||
}
|
||||
|
||||
func (c *k8sConnectivityCollector) Name() string { return "k8s:connectivity" }
|
||||
|
||||
func (c *k8sConnectivityCollector) Interval() time.Duration { return 5 * time.Second }
|
||||
|
||||
func (c *k8sConnectivityCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
now := time.Now()
|
||||
cs, _, err := collectorsk8s.ClientFromCurrentContext()
|
||||
if err != nil {
|
||||
c.tracker.observeFailure(now, err)
|
||||
return c.issuesForFailure(now, err), collectors.Status{Health: collectors.HealthDegraded, Message: "kubeconfig/client error"}, nil
|
||||
}
|
||||
|
||||
// Short ping to validate reachability.
|
||||
pingErr := collectorsk8s.Ping(ctx, cs)
|
||||
if pingErr == nil {
|
||||
c.tracker.observeSuccess()
|
||||
return nil, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
c.tracker.observeFailure(now, pingErr)
|
||||
return c.issuesForFailure(now, pingErr), collectors.Status{Health: collectors.HealthDegraded, Message: "k8s ping failed"}, nil
|
||||
}
|
||||
|
||||
func (c *k8sConnectivityCollector) issuesForFailure(now time.Time, err error) []model.Issue {
|
||||
if c.tracker.shouldEmit(now) {
|
||||
return []model.Issue{model.Issue{
|
||||
ID: "k8s:cluster:unreachable",
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP0,
|
||||
Title: "Kubernetes cluster unreachable / auth failed",
|
||||
Details: fmt.Sprintf("Kubernetes API unreachable or credentials invalid. Last error: %v", err),
|
||||
Evidence: map[string]string{"reason": "Unreachable"},
|
||||
SuggestedFix: "kubectl cluster-info\nkubectl get nodes",
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep otherwise-unused constants referenced.
|
||||
var _ = []any{collectors.HealthOK, collectorTimeoutFast, collectorTimeoutK8sList}
|
||||
@@ -0,0 +1,681 @@
|
||||
# All Future Features Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement all items in `PLAN.md` under "Future Features (Tracked)" for the `porthole` app.
|
||||
|
||||
**Architecture:** Add features incrementally behind small, testable boundaries: shared-secret admin auth for writes, a normalized derived-variant model for media, a transcoding pipeline (MP4 first), tagging/albums and metadata overrides with audit logging, dedupe + moments, GPS extraction + map UI (no reverse geocoding), endpoint selection for presigned URLs, and CI-based multi-arch builds.
|
||||
|
||||
**Tech Stack:** Bun workspaces, Next.js API route handlers (`apps/web/app/api/**/route.ts`), Node worker + BullMQ (`apps/worker/src/jobs.ts`), Postgres migrations (`packages/db/migrations/*.sql`), MinIO (S3) clients (`packages/minio/src/index.ts`), Helm (`helm/porthole/*`).
|
||||
|
||||
## Preconditions / Ground Rules
|
||||
|
||||
- Do not mutate or delete anything under `originals/`.
|
||||
- Prefer additive schema changes first; deprecate old columns after compatibility is maintained.
|
||||
- Use Bun’s test runner (`bun test`) for new TypeScript tests.
|
||||
- Keep Pi constraints: CPU-heavy work stays in worker; keep transcoding concurrency low.
|
||||
|
||||
## Phase 0: Test Harness + Repo Hygiene
|
||||
|
||||
### Task 0.1: Add Bun test runner scripts
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `package.json`
|
||||
- Create: `apps/web/src/__tests__/smoke.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/web/src/__tests__/smoke.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("bun test runs", () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: FAIL (no tests configured / command missing)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add script to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json apps/web/src/__tests__/smoke.test.ts
|
||||
git commit -m "test: add bun test runner"
|
||||
```
|
||||
|
||||
## Phase 1: Shared-Secret Admin Auth (Write Protection)
|
||||
|
||||
### Task 1.1: Add ADMIN_TOKEN env + helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/config/src/index.ts`
|
||||
- Create: `packages/config/src/adminAuth.ts`
|
||||
- Test: `packages/config/src/adminAuth.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `packages/config/src/adminAuth.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
import { isAdminRequest } from "./adminAuth";
|
||||
|
||||
test("isAdminRequest returns false when ADMIN_TOKEN unset", () => {
|
||||
expect(isAdminRequest({ adminToken: undefined }, { headerToken: "x" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAdminRequest returns true when header token matches", () => {
|
||||
expect(
|
||||
isAdminRequest({ adminToken: "secret" }, { headerToken: "secret" }),
|
||||
).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test packages/config/src/adminAuth.test.ts`
|
||||
Expected: FAIL (module missing)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create `packages/config/src/adminAuth.ts`:
|
||||
|
||||
```ts
|
||||
export function isAdminRequest(
|
||||
env: { adminToken: string | undefined },
|
||||
input: { headerToken: string | null | undefined },
|
||||
) {
|
||||
if (!env.adminToken) return false;
|
||||
return input.headerToken === env.adminToken;
|
||||
}
|
||||
```
|
||||
|
||||
Extend `packages/config/src/index.ts` to parse `ADMIN_TOKEN` (optional) and export `getAdminToken()`.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test packages/config/src/adminAuth.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/config/src/index.ts packages/config/src/adminAuth.ts packages/config/src/adminAuth.test.ts
|
||||
git commit -m "feat: add admin token config and auth helper"
|
||||
```
|
||||
|
||||
### Task 1.2: Enforce admin on mutation API routes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/app/api/imports/route.ts`
|
||||
- Modify: `apps/web/app/api/imports/[id]/upload/route.ts`
|
||||
- Modify: `apps/web/app/api/imports/[id]/scan-minio/route.ts`
|
||||
- Test: `apps/web/src/__tests__/admin-gates-imports.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/web/src/__tests__/admin-gates-imports.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
// This test intentionally asserts the handler behavior by calling the route function.
|
||||
// It will require exporting a small pure helper from each route in the implementation.
|
||||
|
||||
test("imports POST rejects when missing admin token", async () => {
|
||||
const { handleCreateImport } = await import("../../app/api/imports/handlers");
|
||||
const res = await handleCreateImport({ adminOk: false, body: {} });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/admin-gates-imports.test.ts`
|
||||
Expected: FAIL (handlers module missing)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/web/app/api/imports/handlers.ts` exporting pure functions that return `{ status, body }` for tests.
|
||||
- Update `apps/web/app/api/imports/route.ts` to:
|
||||
- read `X-Porthole-Admin-Token`
|
||||
- compute adminOk via `@tline/config` helper
|
||||
- reject with 401 `{ error: "admin_required" }` when not admin
|
||||
- Repeat pattern for upload + scan routes.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/admin-gates-imports.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/api/imports/route.ts apps/web/app/api/imports/handlers.ts \
|
||||
apps/web/app/api/imports/[id]/upload/route.ts apps/web/app/api/imports/[id]/scan-minio/route.ts \
|
||||
apps/web/src/__tests__/admin-gates-imports.test.ts
|
||||
git commit -m "feat: require admin token for ingestion endpoints"
|
||||
```
|
||||
|
||||
## Phase 2: Derived Variants Model (Thumbs/Posters/Video)
|
||||
|
||||
### Task 2.1: Add derived variants table + minimal writer/reader
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/db/migrations/0003_asset_variants.sql`
|
||||
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
|
||||
- Modify: `apps/worker/src/jobs.ts`
|
||||
- Test: `apps/web/src/__tests__/variant-url-404.test.ts`
|
||||
|
||||
**Schema (migration):**
|
||||
|
||||
```sql
|
||||
CREATE TYPE IF NOT EXISTS asset_variant_kind AS ENUM (
|
||||
'thumb',
|
||||
'poster',
|
||||
'video_mp4'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_variants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
kind asset_variant_kind NOT NULL,
|
||||
size int NOT NULL,
|
||||
key text NOT NULL,
|
||||
mime_type text NOT NULL,
|
||||
width int,
|
||||
height int,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(asset_id, kind, size)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS asset_variants_asset_id_idx ON asset_variants(asset_id);
|
||||
```
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/web/src/__tests__/variant-url-404.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("/api/assets/:id/url returns 404 when requested variant missing", async () => {
|
||||
const { pickVariantKey } =
|
||||
await import("../../app/api/assets/[id]/url/variant");
|
||||
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
|
||||
expect(key).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/variant-url-404.test.ts`
|
||||
Expected: FAIL (module missing)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/web/app/api/assets/[id]/url/variant.ts`:
|
||||
|
||||
```ts
|
||||
export function pickVariantKey(
|
||||
input: { variants: Array<{ kind: string; size: number; key: string }> },
|
||||
req: { kind: string; size: number },
|
||||
) {
|
||||
const v = input.variants.find(
|
||||
(x) => x.kind === req.kind && x.size === req.size,
|
||||
);
|
||||
return v?.key ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
- Update `apps/web/app/api/assets/[id]/url/route.ts` to support query:
|
||||
- `kind=original|thumb|poster|video_mp4`
|
||||
- `size=<int>` (required for non-original)
|
||||
- Keep backward-compatible `variant=thumb_small|thumb_med|poster|original` for now.
|
||||
|
||||
- Update `apps/worker/src/jobs.ts` to insert rows into `asset_variants` when it uploads thumbs/posters.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/variant-url-404.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/db/migrations/0003_asset_variants.sql \
|
||||
apps/web/app/api/assets/[id]/url/route.ts apps/web/app/api/assets/[id]/url/variant.ts \
|
||||
apps/web/src/__tests__/variant-url-404.test.ts apps/worker/src/jobs.ts
|
||||
git commit -m "feat: add asset variants table and URL selection"
|
||||
```
|
||||
|
||||
### Task 2.2: Multiple thumb + poster sizes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/worker/src/jobs.ts`
|
||||
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
|
||||
- Test: `apps/worker/src/__tests__/variants-sizes.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/worker/src/__tests__/variants-sizes.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
import { computeImageVariantPlan } from "../variants";
|
||||
|
||||
test("computeImageVariantPlan includes 256 and 768 thumbs", () => {
|
||||
expect(computeImageVariantPlan()).toEqual([
|
||||
{ kind: "thumb", size: 256 },
|
||||
{ kind: "thumb", size: 768 },
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/worker/src/__tests__/variants-sizes.test.ts`
|
||||
Expected: FAIL (module missing)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/worker/src/variants.ts` with exported `computeImageVariantPlan()` and `computeVideoPosterPlan()`.
|
||||
- Refactor `apps/worker/src/jobs.ts` to use these plans and generate additional poster size(s) (e.g. 256 + 768).
|
||||
- Insert each uploaded object into `asset_variants` with (kind,size,key,mime_type,width,height).
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/worker/src/__tests__/variants-sizes.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/worker/src/jobs.ts apps/worker/src/variants.ts apps/worker/src/__tests__/variants-sizes.test.ts
|
||||
git commit -m "feat: generate multiple thumbs and posters"
|
||||
```
|
||||
|
||||
## Phase 3: Video Transcoding + Prefer-Derived Playback
|
||||
|
||||
### Task 3.1: Add MP4 transcode worker job
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/queue/src/index.ts`
|
||||
- Modify: `apps/worker/src/jobs.ts`
|
||||
- Test: `apps/worker/src/__tests__/transcode-plan.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/worker/src/__tests__/transcode-plan.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
import { shouldTranscodeToMp4 } from "../transcode";
|
||||
|
||||
test("transcode runs for non-mp4 videos", () => {
|
||||
expect(shouldTranscodeToMp4({ mimeType: "video/x-matroska" })).toBe(true);
|
||||
});
|
||||
|
||||
test("transcode skips for mp4", () => {
|
||||
expect(shouldTranscodeToMp4({ mimeType: "video/mp4" })).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/worker/src/__tests__/transcode-plan.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/worker/src/transcode.ts` implementing `shouldTranscodeToMp4`.
|
||||
- Add BullMQ job payload + enqueue helper (e.g. `enqueueTranscodeVideoMp4({ assetId })`).
|
||||
- In `handleProcessAsset` for video, enqueue mp4 transcode when needed.
|
||||
- Implement ffmpeg transcode to `derived/video/${assetId}/mp4_720p.mp4` (H.264 + AAC, fast preset).
|
||||
- Insert into `asset_variants` as `kind='video_mp4', size=720, mime_type='video/mp4'`.
|
||||
- Keep concurrency low (1) in worker for transcodes.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/worker/src/__tests__/transcode-plan.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/queue/src/index.ts apps/worker/src/jobs.ts apps/worker/src/transcode.ts \
|
||||
apps/worker/src/__tests__/transcode-plan.test.ts
|
||||
git commit -m "feat: add mp4 transcode job and variant record"
|
||||
```
|
||||
|
||||
### Task 3.2: Prefer derived in URL endpoint + viewer
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
- Modify: `apps/web/app/components/Viewer.tsx`
|
||||
- Test: `apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Create `apps/web/src/__tests__/prefer-derived.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
|
||||
|
||||
test("prefer mp4 derived over original", () => {
|
||||
const picked = pickVideoPlaybackVariant({
|
||||
originalMimeType: "video/x-matroska",
|
||||
variants: [{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }],
|
||||
});
|
||||
expect(picked).toEqual({ kind: "video_mp4", size: 720 });
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/web/app/lib/playback.ts` implementing deterministic selection.
|
||||
- Update viewer to:
|
||||
- ask server for `kind=video_mp4&size=720` first
|
||||
- fall back to `original`
|
||||
- Keep existing poster behavior.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/api/assets/[id]/url/route.ts apps/web/app/lib/playback.ts \
|
||||
apps/web/app/components/MediaPanel.tsx apps/web/app/components/Viewer.tsx \
|
||||
apps/web/src/__tests__/prefer-derived.test.ts
|
||||
git commit -m "feat: prefer derived mp4 playback with fallback"
|
||||
```
|
||||
|
||||
## Phase 4: Tags + Albums
|
||||
|
||||
### Task 4.1: Schema for tags/albums + audit log
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/db/migrations/0004_tags_albums_audit.sql`
|
||||
|
||||
**Migration (example):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_tags (
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(asset_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS album_assets (
|
||||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
ord int,
|
||||
PRIMARY KEY(album_id, asset_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor text NOT NULL,
|
||||
action text NOT NULL,
|
||||
entity_type text NOT NULL,
|
||||
entity_id uuid,
|
||||
payload jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
**Verification:** run migrator (k8s migrate job or local script) and ensure no SQL errors.
|
||||
|
||||
**Commit:** `git commit -m "feat: add tags, albums, and audit log tables"`
|
||||
|
||||
### Task 4.2: Admin API for tags and albums
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/web/app/api/tags/route.ts`
|
||||
- Create: `apps/web/app/api/albums/route.ts`
|
||||
- Create: `apps/web/app/api/albums/[id]/assets/route.ts`
|
||||
- Test: `apps/web/src/__tests__/tags-admin-auth.test.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- RED: test that POST without admin returns 401
|
||||
- GREEN: implement CRUD (minimal: list + create; album add/remove assets)
|
||||
- REFACTOR: write audit_log rows on each mutation
|
||||
|
||||
**Commit:** `feat: add admin tags and albums APIs`
|
||||
|
||||
### Task 4.3: UI wiring for tags/albums
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/app/admin/page.tsx`
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Add minimal admin form to set admin token in browser (sessionStorage) and to create/list tags and albums.
|
||||
- Add UI on asset detail to assign tags, and to add asset to album.
|
||||
- Keep UX resilient (errors render inline, don’t crash).
|
||||
|
||||
**Commit:** `feat: add tags/albums UI`
|
||||
|
||||
## Phase 5: Metadata Overrides + Timeline Uses Overrides
|
||||
|
||||
### Task 5.1: Override table + API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/db/migrations/0005_asset_overrides.sql`
|
||||
- Create: `apps/web/app/api/assets/[id]/override-capture-ts/route.ts`
|
||||
- Modify: `apps/web/app/api/tree/route.ts`
|
||||
- Modify: `apps/web/app/api/assets/route.ts`
|
||||
|
||||
**Migration:** table `asset_overrides(asset_id PK, capture_ts_utc_override timestamptz, capture_offset_minutes_override int, created_at...)`.
|
||||
|
||||
**Steps:**
|
||||
|
||||
- RED: test route rejects without admin
|
||||
- GREEN: implement POST to set override and insert audit_log
|
||||
- GREEN: update aggregation queries to use `COALESCE(overrides.capture_ts_utc_override, assets.capture_ts_utc)`
|
||||
|
||||
**Commit:** `feat: add capture time overrides and apply in queries`
|
||||
|
||||
### Task 5.2: UI for capture-time override
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/web/app/components/Viewer.tsx`
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Add form to set ISO timestamp override and submit to API.
|
||||
- Display current effective timestamp and base timestamp.
|
||||
|
||||
**Commit:** `feat: add UI for capture time override`
|
||||
|
||||
## Phase 6: GPS Extraction + Map UI (No Reverse Geocode)
|
||||
|
||||
### Task 6.1: Add gps fields + extraction
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/db/migrations/0006_assets_gps.sql`
|
||||
- Modify: `apps/worker/src/jobs.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Add columns `gps_lat double precision`, `gps_lon double precision` (nullable)
|
||||
- Parse ExifTool GPS fields for images (and where available for videos) and store them.
|
||||
|
||||
**Commit:** `feat: extract and store GPS coords`
|
||||
|
||||
### Task 6.2: Map UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/web/app/map/page.tsx`
|
||||
- Modify: `apps/web/app/page.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Show a simple map view with markers for assets that have GPS.
|
||||
- If tiles unavailable, show a clear fallback message.
|
||||
|
||||
**Commit:** `feat: add map page for GPS assets`
|
||||
|
||||
## Phase 7: Dedupe by Hash + Moments
|
||||
|
||||
### Task 7.1: Hash table + compute sha256
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/db/migrations/0007_asset_hashes.sql`
|
||||
- Modify: `apps/worker/src/jobs.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- During download to temp file, compute sha256 and store it.
|
||||
- Add unique index on `(bucket, sha256)` optionally (careful for partial/unknown).
|
||||
|
||||
**Commit:** `feat: compute asset sha256 for dedupe`
|
||||
|
||||
### Task 7.2: Dedupe detection + API
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/web/app/api/assets/[id]/dupes/route.ts`
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Endpoint returns assets with same sha256.
|
||||
- UI indicates duplicates.
|
||||
|
||||
**Commit:** `feat: expose and display duplicates`
|
||||
|
||||
### Task 7.3: Moments clustering
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/web/app/api/moments/route.ts`
|
||||
- Create: `apps/web/app/lib/moments.ts`
|
||||
- Test: `apps/web/src/__tests__/moments.test.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- RED: test that assets within 30 minutes cluster together
|
||||
- GREEN: implement clustering
|
||||
- Wire UI to show moments as sub-groups
|
||||
|
||||
**Commit:** `feat: add day moments clustering`
|
||||
|
||||
## Phase 8: Presign Endpoint Selection (LAN vs Tailnet)
|
||||
|
||||
### Task 8.1: Add endpoint mode to config and presign
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/minio/src/index.ts`
|
||||
- Modify: `packages/config/src/index.ts`
|
||||
- Modify: `apps/web/app/api/assets/[id]/url/route.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Add env for `MINIO_PUBLIC_ENDPOINT_LAN` and `MINIO_ENDPOINT_MODE=tailnet|lan|auto`.
|
||||
- If `endpoint=lan|tailnet` query param is provided, force that.
|
||||
- In `auto`, use tailnet as safe default.
|
||||
|
||||
**Commit:** `feat: support lan/tailnet endpoint selection for presigned URLs`
|
||||
|
||||
## Phase 9: Storage Policies (Derived Lifecycle) + CI Builds
|
||||
|
||||
### Task 9.1: Optional MinIO lifecycle policy job
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `helm/porthole/values.yaml`
|
||||
- Modify: `helm/porthole/templates/job-ensure-bucket.yaml.tpl`
|
||||
- Create: `helm/porthole/templates/job-apply-lifecycle.yaml.tpl`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Add optional Job to apply lifecycle rules for prefixes `thumbs/` and `derived/` (expire after N days) without touching `originals/`.
|
||||
|
||||
**Commit:** `feat: add optional lifecycle policy job`
|
||||
|
||||
### Task 9.2: Add CI pipeline for multi-arch builds
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.gitea/workflows/build-images.yml` (or alternative supported by your CI)
|
||||
- Modify: `README.md`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- Build and push multi-arch images for `apps/web` and `apps/worker`.
|
||||
- Run: `bun run typecheck`.
|
||||
- Run: `bash run_tests.sh` (Go tests) to keep repo green.
|
||||
|
||||
**Commit:** `ci: build and push multi-arch images`
|
||||
|
||||
## Verification Checklist (Per Phase)
|
||||
|
||||
- `bun test`
|
||||
- `bun run typecheck`
|
||||
- `bash run_tests.sh`
|
||||
- Helm template renders: `helm template porthole helm/porthole -f your-values.yaml --namespace porthole`
|
||||
@@ -0,0 +1,109 @@
|
||||
# Use Playback Selector Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add an asset variants endpoint and wire MediaPanel to use `pickVideoPlaybackVariant` for derived MP4 selection with a safe fallback.
|
||||
|
||||
**Architecture:** Introduce a minimal `/api/assets/:id/variants` route that returns `{ kind, size, key }` from `asset_variants`. MediaPanel fetches variants on-demand for videos, uses `pickVideoPlaybackVariant` to decide whether to request `video_mp4` (size 720), and falls back to original if the derived URL fails.
|
||||
|
||||
**Tech Stack:** Next.js App Router API routes, Postgres via `@tline/db`, Bun test runner.
|
||||
|
||||
### Task 1: Add variants API route
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/app/api/assets/[id]/variants/route.ts`
|
||||
- Test: `apps/web/src/__tests__/variants-route.test.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `apps/web/src/__tests__/variants-route.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("variants route returns only kind/size/key fields", async () => {
|
||||
const { shapeVariants } = await import("../../app/api/assets/[id]/variants/shape");
|
||||
const rows = [
|
||||
{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4", mime_type: "video/mp4" },
|
||||
];
|
||||
expect(shapeVariants(rows)).toEqual([{ kind: "video_mp4", size: 720, key: "derived/video/a.mp4" }]);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
|
||||
Expected: FAIL (missing module or function)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create `apps/web/app/api/assets/[id]/variants/shape.ts` with `shapeVariants(rows)` that returns `{ kind, size, key }` only.
|
||||
- Create `apps/web/app/api/assets/[id]/variants/route.ts`:
|
||||
- Validate `id` with `z.string().uuid()`
|
||||
- Query `asset_variants` by `asset_id`
|
||||
- Return JSON array of `shapeVariants(rows)`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/variants-route.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/api/assets/[id]/variants/route.ts apps/web/app/api/assets/[id]/variants/shape.ts \
|
||||
apps/web/src/__tests__/variants-route.test.ts
|
||||
git commit -m "feat: add asset variants endpoint"
|
||||
```
|
||||
|
||||
### Task 2: Use playback selector in MediaPanel
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
- Modify: `apps/web/app/lib/playback.ts`
|
||||
- Test: `apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add to `apps/web/src/__tests__/prefer-derived.test.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "bun:test";
|
||||
import { pickVideoPlaybackVariant } from "../../app/lib/playback";
|
||||
|
||||
test("pickVideoPlaybackVariant returns null when no variants", () => {
|
||||
expect(
|
||||
pickVideoPlaybackVariant({
|
||||
originalMimeType: "video/x-matroska",
|
||||
variants: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
Expected: FAIL (function does not handle empty variants)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Update `pickVideoPlaybackVariant` to return `null` when no `video_mp4` variants exist.
|
||||
- Update `MediaPanel` video URL loader to:
|
||||
1) Fetch `/api/assets/:id/variants`
|
||||
2) Call `pickVideoPlaybackVariant`
|
||||
3) If variant found → request `kind=video_mp4&size=720`
|
||||
4) If not found or fetch fails → request `variant=original`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test apps/web/src/__tests__/prefer-derived.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/components/MediaPanel.tsx apps/web/app/lib/playback.ts \
|
||||
apps/web/src/__tests__/prefer-derived.test.ts
|
||||
git commit -m "fix: use playback selector in MediaPanel"
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Tags/Albums UI Wiring Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add minimal admin UI to manage tags/albums and wire asset detail UI to assign tags and add assets to albums.
|
||||
|
||||
**Architecture:** Keep UI changes local to existing Next.js components. Use lightweight fetch calls to existing `/api/tags` and `/api/albums` endpoints with admin header set from sessionStorage, plus inline error handling. Avoid new state management or styling systems.
|
||||
|
||||
**Tech Stack:** Next.js app router, React, TypeScript, fetch API, inline styles/Tailwind classes.
|
||||
|
||||
### Task 1: Establish admin token input + list/create tags/albums UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/admin/page.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Skipped.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Convert page to client component.
|
||||
- Add admin token form that reads/writes `sessionStorage`.
|
||||
- Add list + create for tags and albums using `fetch` with `X-Porthole-Admin-Token` header.
|
||||
- Inline errors per section.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Skipped.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
Include with other tasks once all UI wiring is complete.
|
||||
|
||||
### Task 2: Asset detail UI for tag assignment and album add
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Skipped.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Add UI section in viewer panel to assign tag(s) to the current asset and add asset to album.
|
||||
- Fetch tags/albums lists using admin token from `sessionStorage`.
|
||||
- Use inline error handling and disable actions when missing token/asset.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Skipped.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
Include with other tasks once all UI wiring is complete.
|
||||
|
||||
### Task 3: Validate behavior manually
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Skipped.
|
||||
|
||||
**Step 3: Manual smoke**
|
||||
|
||||
- Load `/admin` page, set token, create tag/album, verify list refresh.
|
||||
- Open asset viewer in media panel, assign tag/add to album, confirm inline success/error states.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/admin/page.tsx apps/web/app/components/MediaPanel.tsx docs/plans/2026-02-02-tags-albums-ui.md
|
||||
git commit -m "feat: add tags/albums UI"
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# Capture Time Override UI Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a capture-time override form in the MediaPanel viewer to POST override timestamps and display current effective/base timestamps.
|
||||
|
||||
**Architecture:** Extend the existing MediaPanel viewer admin controls with a small form that reads/writes to `/api/assets/:id/override-capture-ts` using the existing admin token from sessionStorage. Keep UI state local to MediaPanel and refresh the viewer asset timestamps after submit.
|
||||
|
||||
**Tech Stack:** React (Next.js app router), TypeScript, fetch API
|
||||
|
||||
### Task 1: Add override state and helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests (user-approved).
|
||||
|
||||
**Step 2: Add state for override input, status, and effective/base timestamps**
|
||||
|
||||
```ts
|
||||
const [captureOverrideInput, setCaptureOverrideInput] = useState("");
|
||||
const [captureOverrideError, setCaptureOverrideError] = useState<string | null>(null);
|
||||
const [captureOverrideBusy, setCaptureOverrideBusy] = useState(false);
|
||||
```
|
||||
|
||||
**Step 3: Add helper to derive effective/base timestamp**
|
||||
|
||||
```ts
|
||||
const effectiveTs = viewer?.asset.capture_ts_utc ?? null;
|
||||
const baseTs = viewer?.asset.capture_ts_utc ?? null; // updated when override applied
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
No commit yet; continue tasks.
|
||||
|
||||
### Task 2: Add override POST handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests (user-approved).
|
||||
|
||||
**Step 2: Implement submit handler**
|
||||
|
||||
```ts
|
||||
async function handleOverrideCaptureTs() {
|
||||
if (!viewer) return;
|
||||
setCaptureOverrideError(null);
|
||||
setCaptureOverrideBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
const res = await fetch(`/api/assets/${viewer.asset.id}/override-capture-ts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ capture_ts_utc: captureOverrideInput || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`override_failed:${res.status}`);
|
||||
// refresh viewer asset timestamps (re-fetch list or update local)
|
||||
} catch (err) {
|
||||
setCaptureOverrideError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCaptureOverrideBusy(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
No commit yet; continue tasks.
|
||||
|
||||
### Task 3: Add UI above Tags & Albums
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No tests (user-approved).
|
||||
|
||||
**Step 2: Add form UI**
|
||||
|
||||
```tsx
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<strong style={{ fontSize: 13 }}>Capture time override</strong>
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
Effective: {effectiveTs ?? "(none)"}
|
||||
</div>
|
||||
<div style={{ color: "#999", fontSize: 12 }}>
|
||||
Base: {baseTs ?? "(unknown)"}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="2026-01-01T00:00:00.000Z"
|
||||
value={captureOverrideInput}
|
||||
onChange={(e) => setCaptureOverrideInput(e.target.value)}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
disabled={captureOverrideBusy}
|
||||
/>
|
||||
<button type="button" onClick={handleOverrideCaptureTs}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{captureOverrideError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>{captureOverrideError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
No commit yet; continue tasks.
|
||||
|
||||
### Task 4: Finalize, verify, and commit
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/components/MediaPanel.tsx`
|
||||
|
||||
**Step 1: Quick manual check**
|
||||
|
||||
Run: `npm test` (skip)
|
||||
Expected: (skipped per user)
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/components/MediaPanel.tsx
|
||||
git commit -m "feat: add UI for capture time override"
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
module tower
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
k8s.io/api v0.30.3
|
||||
k8s.io/apimachinery v0.30.3
|
||||
k8s.io/client-go v0.30.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.22.3 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/imdario/mergo v0.3.6 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/oauth2 v0.10.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/term v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.120.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY=
|
||||
github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM=
|
||||
github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE=
|
||||
github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
|
||||
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ=
|
||||
golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.30.3 h1:ImHwK9DCsPA9uoU3rVh4QHAHHK5dTSv1nxJUapx8hoQ=
|
||||
k8s.io/api v0.30.3/go.mod h1:GPc8jlzoe5JG3pb0KJCSLX5oAFIW3/qNJITlDj8BH04=
|
||||
k8s.io/apimachinery v0.30.3 h1:q1laaWCmrszyQuSQCfNB8cFgCuDAoPszKY4ucAjDwHc=
|
||||
k8s.io/apimachinery v0.30.3/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc=
|
||||
k8s.io/client-go v0.30.3 h1:bHrJu3xQZNXIi8/MoxYtZBBWQQXwy16zqJwloXXfD3k=
|
||||
k8s.io/client-go v0.30.3/go.mod h1:8d4pf8vYu665/kUbsxWAQ/JDBNWqfFeZnvFiVdmx89U=
|
||||
k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw=
|
||||
k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
|
||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
@@ -0,0 +1,73 @@
|
||||
{{- if and .Values.jobs.applyLifecycle.enabled .Values.minio.enabled -}}
|
||||
{{- $thumbsPrefix := required "applyLifecycle.prefixes.thumbs is required" .Values.jobs.applyLifecycle.prefixes.thumbs -}}
|
||||
{{- $derivedPrefix := required "applyLifecycle.prefixes.derived is required" .Values.jobs.applyLifecycle.prefixes.derived -}}
|
||||
{{- if or (eq $thumbsPrefix "") (eq $derivedPrefix "") -}}
|
||||
{{- fail "applyLifecycle prefixes must be non-empty" -}}
|
||||
{{- end -}}
|
||||
{{- if or (eq $thumbsPrefix "originals/") (eq $derivedPrefix "originals/") (eq $thumbsPrefix "originals") (eq $derivedPrefix "originals") -}}
|
||||
{{- fail "applyLifecycle prefixes must not target originals" -}}
|
||||
{{- end -}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "apply-lifecycle") }}
|
||||
labels:
|
||||
{{ include "tline.labels" . | indent 4 }}
|
||||
app.kubernetes.io/component: apply-lifecycle
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-15"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{ include "tline.selectorLabels" . | indent 8 }}
|
||||
app.kubernetes.io/component: apply-lifecycle
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{ include "tline.imagePullSecrets" . | indent 6 }}
|
||||
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $aff }}
|
||||
affinity:
|
||||
{{ $aff | indent 8 }}
|
||||
{{- end }}
|
||||
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
|
||||
{{- if $tols }}
|
||||
tolerations:
|
||||
{{ $tols | indent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: apply-lifecycle
|
||||
image: {{ printf "%s:%s" .Values.jobs.applyLifecycle.image.repository .Values.jobs.applyLifecycle.image.tag | quote }}
|
||||
imagePullPolicy: {{ .Values.jobs.applyLifecycle.image.pullPolicy }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "Configuring mc alias..."
|
||||
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
|
||||
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
|
||||
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
|
||||
|
||||
echo "Applying lifecycle policy ({{ .Values.jobs.applyLifecycle.expire_days }}d) for derived objects..."
|
||||
mc ilm add --expire-days {{ .Values.jobs.applyLifecycle.expire_days | int }} --prefix {{ .Values.jobs.applyLifecycle.prefixes.thumbs | quote }} "local/{{ .Values.app.minio.bucket }}"
|
||||
mc ilm add --expire-days {{ .Values.jobs.applyLifecycle.expire_days | int }} --prefix {{ .Values.jobs.applyLifecycle.prefixes.derived | quote }} "local/{{ .Values.app.minio.bucket }}"
|
||||
|
||||
# Never mutate or delete originals/**. This job applies lifecycle rules only.
|
||||
env:
|
||||
- name: MINIO_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_ACCESS_KEY_ID
|
||||
- name: MINIO_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: MINIO_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
{{ toYaml .Values.jobs.applyLifecycle.resources | indent 12 }}
|
||||
{{- end }}
|
||||
@@ -56,7 +56,7 @@ spec:
|
||||
imagePullPolicy: {{ .Values.images.minio.pullPolicy }}
|
||||
args:
|
||||
- server
|
||||
- /data
|
||||
- /data/miniodata
|
||||
- "--console-address=:{{ .Values.minio.service.consolePort }}"
|
||||
ports:
|
||||
- name: s3
|
||||
@@ -91,6 +91,7 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
subPath: miniodata
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
|
||||
@@ -63,6 +63,8 @@ spec:
|
||||
secretKeyRef:
|
||||
name: {{ include "tline.secretName" . }}
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Cluster-specific values for porthole deployment
|
||||
|
||||
global:
|
||||
tailscale:
|
||||
tailnetFQDN: "taildb3494.ts.net"
|
||||
|
||||
secrets:
|
||||
postgres:
|
||||
password: "eIXuJEnRWuAs8AafqU3CWJ7Y4CgNAFQ"
|
||||
minio:
|
||||
accessKeyId: "FwGiBwCXKuQdthR1QLa"
|
||||
secretAccessKey: "OtmAz9m7o941wG1Gms2yItyqxd6gCWY8k4LJVBX"
|
||||
|
||||
# Temporarily disable jobs and apps until images are built
|
||||
jobs:
|
||||
migrate:
|
||||
enabled: false
|
||||
|
||||
web:
|
||||
enabled: false
|
||||
worker:
|
||||
enabled: false
|
||||
|
||||
# Reduce MinIO storage for testing (insufficient storage on cluster)
|
||||
minio:
|
||||
storage:
|
||||
size: 20Gi
|
||||
@@ -231,6 +231,24 @@ jobs:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
|
||||
applyLifecycle:
|
||||
enabled: false
|
||||
expire_days: 30
|
||||
prefixes:
|
||||
thumbs: thumbs/
|
||||
derived: derived/
|
||||
image:
|
||||
repository: minio/mc
|
||||
tag: RELEASE.2024-01-16T16-07-38Z
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
|
||||
migrate:
|
||||
enabled: true
|
||||
image:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
type Health string
|
||||
|
||||
const (
|
||||
HealthOK Health = "OK"
|
||||
HealthDegraded Health = "DEGRADED"
|
||||
HealthError Health = "ERROR"
|
||||
)
|
||||
|
||||
// Status describes collector health for the current tick.
|
||||
//
|
||||
// Collectors should return Status even when returning an error,
|
||||
// so the UI can show useful context.
|
||||
//
|
||||
// LastSuccess should be the collector's most recent successful collect time.
|
||||
// When unknown, it may be the zero value.
|
||||
//
|
||||
// Message should be short and human-friendly.
|
||||
type Status struct {
|
||||
Health Health `json:"health"`
|
||||
Message string `json:"message,omitempty"`
|
||||
LastSuccess time.Time `json:"last_success,omitempty"`
|
||||
}
|
||||
|
||||
func OKStatus() Status {
|
||||
return Status{Health: HealthOK}
|
||||
}
|
||||
|
||||
// Collector returns "currently true" issues for this tick.
|
||||
//
|
||||
// The store is responsible for dedupe, lifecycle, and resolve-after.
|
||||
// Collectors must respect ctx cancellation.
|
||||
type Collector interface {
|
||||
Name() string
|
||||
Interval() time.Duration
|
||||
Collect(ctx context.Context) ([]model.Issue, Status, error)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// DiskCollector checks filesystem block + inode pressure across mounts.
|
||||
//
|
||||
// It reads /proc/mounts to discover mounts and then uses statfs to compute usage.
|
||||
// Pseudo filesystems are filtered out.
|
||||
//
|
||||
// Thresholds (PLAN.md):
|
||||
// - P1 if blocks OR inodes >= 92%
|
||||
// - P0 if blocks OR inodes >= 98%
|
||||
//
|
||||
// Issues are emitted per mount (one issue that includes both block+inode usage).
|
||||
//
|
||||
// NOTE: This collector is Linux-specific.
|
||||
type DiskCollector struct {
|
||||
interval time.Duration
|
||||
|
||||
readFile func(string) ([]byte, error)
|
||||
statfs func(path string, st *syscall.Statfs_t) error
|
||||
}
|
||||
|
||||
func NewDiskCollector() *DiskCollector {
|
||||
return &DiskCollector{
|
||||
interval: 10 * time.Second,
|
||||
readFile: os.ReadFile,
|
||||
statfs: syscall.Statfs,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DiskCollector) Name() string { return "host:disk" }
|
||||
|
||||
func (c *DiskCollector) Interval() time.Duration {
|
||||
if c.interval <= 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *DiskCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
|
||||
b, err := c.readFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "failed reading /proc/mounts"}, err
|
||||
}
|
||||
|
||||
mounts := parseProcMounts(string(b))
|
||||
if len(mounts) == 0 {
|
||||
// Unusual but treat as degraded rather than hard error.
|
||||
return nil, collectors.Status{Health: collectors.HealthDegraded, Message: "no mounts found"}, nil
|
||||
}
|
||||
|
||||
issues := make([]model.Issue, 0, 8)
|
||||
seenMount := map[string]struct{}{}
|
||||
|
||||
partialErrs := 0
|
||||
for _, m := range mounts {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return issues, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
if shouldSkipMount(m) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenMount[m.MountPoint]; ok {
|
||||
continue
|
||||
}
|
||||
seenMount[m.MountPoint] = struct{}{}
|
||||
|
||||
var st syscall.Statfs_t
|
||||
if err := c.statfs(m.MountPoint, &st); err != nil {
|
||||
partialErrs++
|
||||
continue
|
||||
}
|
||||
|
||||
blockPct, blockFreeBytes := statfsBlockUsedPct(st)
|
||||
inodePct := statfsInodeUsedPct(st)
|
||||
|
||||
pri, ok := diskPriority(blockPct, inodePct)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
evidence := map[string]string{
|
||||
"mount": m.MountPoint,
|
||||
"fstype": m.FSType,
|
||||
"block_used_pct": fmt.Sprintf("%.1f", blockPct),
|
||||
"block_free_bytes": strconv.FormatUint(blockFreeBytes, 10),
|
||||
}
|
||||
if inodePct >= 0 {
|
||||
evidence["inode_used_pct"] = fmt.Sprintf("%.1f", inodePct)
|
||||
}
|
||||
|
||||
issues = append(issues, model.Issue{
|
||||
ID: fmt.Sprintf("host:disk:%s:usage", m.MountPoint),
|
||||
Category: model.CategoryStorage,
|
||||
Priority: pri,
|
||||
Title: fmt.Sprintf("Disk usage high on %s", m.MountPoint),
|
||||
Details: "Filesystem space and/or inodes are nearly exhausted.",
|
||||
Evidence: evidence,
|
||||
SuggestedFix: fmt.Sprintf(
|
||||
"Inspect usage:\n df -h %s\n df -i %s\nFind large directories:\n sudo du -xh --max-depth=2 %s | sort -h | tail",
|
||||
m.MountPoint, m.MountPoint, m.MountPoint,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
st := collectors.OKStatus()
|
||||
if partialErrs > 0 {
|
||||
st.Health = collectors.HealthDegraded
|
||||
st.Message = fmt.Sprintf("partial failures: %d mounts", partialErrs)
|
||||
}
|
||||
return issues, st, nil
|
||||
}
|
||||
|
||||
type procMount struct {
|
||||
Device string
|
||||
MountPoint string
|
||||
FSType string
|
||||
Options string
|
||||
}
|
||||
|
||||
func parseProcMounts(content string) []procMount {
|
||||
s := bufio.NewScanner(strings.NewReader(content))
|
||||
out := make([]procMount, 0, 32)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
m := procMount{
|
||||
Device: unescapeProcMountsField(fields[0]),
|
||||
MountPoint: unescapeProcMountsField(fields[1]),
|
||||
FSType: fields[2],
|
||||
}
|
||||
if len(fields) >= 4 {
|
||||
m.Options = fields[3]
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// /proc/mounts escapes special characters as octal sequences.
|
||||
// The most common one is a space as \040.
|
||||
func unescapeProcMountsField(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"\\040", " ",
|
||||
"\\011", "\t",
|
||||
"\\012", "\n",
|
||||
"\\134", "\\",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
var pseudoFSTypes = map[string]struct{}{
|
||||
"proc": {},
|
||||
"sysfs": {},
|
||||
"tmpfs": {},
|
||||
"devtmpfs": {},
|
||||
"devpts": {},
|
||||
"cgroup": {},
|
||||
"cgroup2": {},
|
||||
"pstore": {},
|
||||
"securityfs": {},
|
||||
"debugfs": {},
|
||||
"tracefs": {},
|
||||
"configfs": {},
|
||||
"hugetlbfs": {},
|
||||
"mqueue": {},
|
||||
"rpc_pipefs": {},
|
||||
"fusectl": {},
|
||||
"binfmt_misc": {},
|
||||
"autofs": {},
|
||||
"bpf": {},
|
||||
"ramfs": {},
|
||||
"nsfs": {},
|
||||
"efivarfs": {},
|
||||
"overlay": {}, // common container overlay mounts
|
||||
|
||||
"squashfs": {}, // typically read-only images
|
||||
"selinuxfs": {},
|
||||
"systemd-1": {},
|
||||
"overlayfs": {}, // (non-standard) conservative skip
|
||||
|
||||
"cgroupfs": {},
|
||||
"procfs": {},
|
||||
"fuse.lxcfs": {},
|
||||
"fuse.gvfsd-fuse": {},
|
||||
}
|
||||
|
||||
func shouldSkipMount(m procMount) bool {
|
||||
if m.MountPoint == "" {
|
||||
return true
|
||||
}
|
||||
// Filter by fstype.
|
||||
if _, ok := pseudoFSTypes[m.FSType]; ok {
|
||||
return true
|
||||
}
|
||||
// Filter common pseudo mountpoints.
|
||||
if strings.HasPrefix(m.MountPoint, "/proc") || strings.HasPrefix(m.MountPoint, "/sys") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(m.MountPoint, "/dev") {
|
||||
// /dev itself can be a real mount in some cases, but usually isn't useful for disk pressure.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func statfsBlockUsedPct(st syscall.Statfs_t) (usedPct float64, freeBytes uint64) {
|
||||
// Mirror df(1) semantics closely:
|
||||
// total = f_blocks
|
||||
// used = f_blocks - f_bfree
|
||||
// avail = f_bavail (space available to unprivileged user)
|
||||
// use% = used / (used + avail)
|
||||
if st.Blocks == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
bsize := uint64(st.Bsize)
|
||||
blocks := uint64(st.Blocks)
|
||||
bfree := uint64(st.Bfree)
|
||||
bavail := uint64(st.Bavail)
|
||||
|
||||
usedBlocks := blocks - bfree
|
||||
denom := usedBlocks + bavail
|
||||
if denom == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
freeBytes = bavail * bsize
|
||||
usedPct = (float64(usedBlocks) / float64(denom)) * 100.0
|
||||
return usedPct, freeBytes
|
||||
}
|
||||
|
||||
// statfsInodeUsedPct returns inode used percent. If inodes are unavailable (f_files==0), returns -1.
|
||||
func statfsInodeUsedPct(st syscall.Statfs_t) float64 {
|
||||
if st.Files == 0 {
|
||||
return -1
|
||||
}
|
||||
total := float64(st.Files)
|
||||
free := float64(st.Ffree)
|
||||
used := total - free
|
||||
return (used / total) * 100.0
|
||||
}
|
||||
|
||||
func diskPriority(blockPct, inodePct float64) (model.Priority, bool) {
|
||||
maxPct := blockPct
|
||||
if inodePct > maxPct {
|
||||
maxPct = inodePct
|
||||
}
|
||||
// inodePct may be -1 if not supported; ignore in that case.
|
||||
if inodePct < 0 {
|
||||
maxPct = blockPct
|
||||
}
|
||||
|
||||
switch {
|
||||
case maxPct >= 98.0:
|
||||
return model.PriorityP0, true
|
||||
case maxPct >= 92.0:
|
||||
return model.PriorityP1, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
var _ collectors.Collector = (*DiskCollector)(nil)
|
||||
@@ -0,0 +1,80 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseProcMounts_UnescapesAndParses(t *testing.T) {
|
||||
in := "dev1 / ext4 rw 0 0\n" +
|
||||
"dev2 /path\\040with\\040space xfs rw 0 0\n" +
|
||||
"badline\n"
|
||||
|
||||
ms := parseProcMounts(in)
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("expected 2 mounts, got %d", len(ms))
|
||||
}
|
||||
if ms[0].MountPoint != "/" || ms[0].FSType != "ext4" {
|
||||
t.Fatalf("unexpected first mount: %+v", ms[0])
|
||||
}
|
||||
if ms[1].MountPoint != "/path with space" {
|
||||
t.Fatalf("expected unescaped mountpoint, got %q", ms[1].MountPoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipMount_FiltersPseudo(t *testing.T) {
|
||||
cases := []procMount{
|
||||
{MountPoint: "/proc", FSType: "proc"},
|
||||
{MountPoint: "/sys", FSType: "sysfs"},
|
||||
{MountPoint: "/dev", FSType: "tmpfs"},
|
||||
{MountPoint: "/dev/shm", FSType: "tmpfs"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !shouldSkipMount(c) {
|
||||
t.Fatalf("expected skip for %+v", c)
|
||||
}
|
||||
}
|
||||
if shouldSkipMount(procMount{MountPoint: "/home", FSType: "ext4"}) {
|
||||
t.Fatalf("did not expect skip for /home ext4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskPriority(t *testing.T) {
|
||||
if p, ok := diskPriority(91.9, -1); ok {
|
||||
t.Fatalf("expected no issue, got %v", p)
|
||||
}
|
||||
if p, ok := diskPriority(92.0, -1); !ok || p != "P1" {
|
||||
t.Fatalf("expected P1 at 92%%, got %v ok=%v", p, ok)
|
||||
}
|
||||
if p, ok := diskPriority(97.9, 98.0); !ok || p != "P0" {
|
||||
t.Fatalf("expected P0 if either crosses 98%%, got %v ok=%v", p, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatfsCalculations(t *testing.T) {
|
||||
st := syscall.Statfs_t{}
|
||||
st.Bsize = 1
|
||||
st.Blocks = 100
|
||||
st.Bfree = 8
|
||||
st.Bavail = 8
|
||||
|
||||
pct, free := statfsBlockUsedPct(st)
|
||||
if free != 8 {
|
||||
t.Fatalf("expected free=8 bytes, got %d", free)
|
||||
}
|
||||
if pct < 91.9 || pct > 92.1 {
|
||||
t.Fatalf("expected ~92%% used, got %f", pct)
|
||||
}
|
||||
|
||||
st.Files = 100
|
||||
st.Ffree = 2
|
||||
ipct := statfsInodeUsedPct(st)
|
||||
if ipct < 97.9 || ipct > 98.1 {
|
||||
t.Fatalf("expected ~98%% inode used, got %f", ipct)
|
||||
}
|
||||
|
||||
st.Files = 0
|
||||
if statfsInodeUsedPct(st) != -1 {
|
||||
t.Fatalf("expected -1 when inode info unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// LoadCollector evaluates 1-minute load average normalized by logical CPU count.
|
||||
//
|
||||
// Thresholds (PLAN.md), normalized by CPU count:
|
||||
// - P2 if load1/cpus >= 4.0 sustained 120s
|
||||
// - P1 if load1/cpus >= 6.0 sustained 120s
|
||||
//
|
||||
// NOTE: Linux-specific.
|
||||
// Thread-safe: Collect() can be called concurrently.
|
||||
type LoadCollector struct {
|
||||
interval time.Duration
|
||||
|
||||
now func() time.Time
|
||||
readFile func(string) ([]byte, error)
|
||||
cpuCount func() int
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
pri model.Priority
|
||||
since time.Time
|
||||
}
|
||||
|
||||
func NewLoadCollector() *LoadCollector {
|
||||
return &LoadCollector{
|
||||
interval: 5 * time.Second,
|
||||
now: time.Now,
|
||||
readFile: os.ReadFile,
|
||||
cpuCount: runtime.NumCPU,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LoadCollector) Name() string { return "host:load" }
|
||||
|
||||
func (c *LoadCollector) Interval() time.Duration {
|
||||
if c.interval <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *LoadCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
|
||||
now := c.now()
|
||||
b, err := c.readFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "failed reading /proc/loadavg"}, err
|
||||
}
|
||||
|
||||
load1, err := parseProcLoadavgFirst(string(b))
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthDegraded, Message: "bad /proc/loadavg"}, nil
|
||||
}
|
||||
|
||||
cpus := c.cpuCount()
|
||||
if cpus <= 0 {
|
||||
cpus = 1
|
||||
}
|
||||
norm := load1 / float64(cpus)
|
||||
desired, window := desiredLoadPriority(norm)
|
||||
c.mu.Lock()
|
||||
c.pri, c.since = updateSustained(now, c.pri, c.since, desired)
|
||||
pri, since := c.pri, c.since
|
||||
c.mu.Unlock()
|
||||
|
||||
if pri == "" || since.IsZero() || now.Sub(since) < window {
|
||||
return nil, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
iss := model.Issue{
|
||||
ID: "host:load:high",
|
||||
Category: model.CategoryPerformance,
|
||||
Priority: pri,
|
||||
Title: "High sustained system load",
|
||||
Details: "The 1-minute load average is high relative to CPU count for a sustained period.",
|
||||
Evidence: map[string]string{
|
||||
"load1": fmt.Sprintf("%.2f", load1),
|
||||
"cpus": strconv.Itoa(cpus),
|
||||
"load1_per_cpu": fmt.Sprintf("%.2f", norm),
|
||||
"sustained_window": window.String(),
|
||||
},
|
||||
SuggestedFix: "Investigate CPU hogs:\n top\n ps -eo pid,ppid,cmd,%cpu --sort=-%cpu | head\nIf I/O bound (high iowait), check disk/network.\n",
|
||||
}
|
||||
return []model.Issue{iss}, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
func parseProcLoadavgFirst(content string) (float64, error) {
|
||||
// /proc/loadavg format: "1.23 0.70 0.50 1/123 4567".
|
||||
fields := strings.Fields(content)
|
||||
if len(fields) < 1 {
|
||||
return 0, fmt.Errorf("missing fields")
|
||||
}
|
||||
v, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func desiredLoadPriority(loadPerCPU float64) (model.Priority, time.Duration) {
|
||||
if loadPerCPU >= 6.0 {
|
||||
return model.PriorityP1, 120 * time.Second
|
||||
}
|
||||
if loadPerCPU >= 4.0 {
|
||||
return model.PriorityP2, 120 * time.Second
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
var _ collectors.Collector = (*LoadCollector)(nil)
|
||||
@@ -0,0 +1,48 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
func TestParseProcLoadavgFirst(t *testing.T) {
|
||||
v, err := parseProcLoadavgFirst("1.23 0.70 0.50 1/123 4567\n")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if v < 1.229 || v > 1.231 {
|
||||
t.Fatalf("expected 1.23, got %v", v)
|
||||
}
|
||||
if _, err := parseProcLoadavgFirst("\n"); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredLoadPriority(t *testing.T) {
|
||||
p, w := desiredLoadPriority(3.99)
|
||||
if p != "" || w != 0 {
|
||||
t.Fatalf("expected none")
|
||||
}
|
||||
p, w = desiredLoadPriority(4.0)
|
||||
if p != model.PriorityP2 || w != 120*time.Second {
|
||||
t.Fatalf("expected P2/120s")
|
||||
}
|
||||
p, w = desiredLoadPriority(6.0)
|
||||
if p != model.PriorityP1 || w != 120*time.Second {
|
||||
t.Fatalf("expected P1/120s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSustainedWorksForLoadToo(t *testing.T) {
|
||||
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
p, since := updateSustained(now, "", time.Time{}, model.PriorityP2)
|
||||
if p != model.PriorityP2 || !since.Equal(now) {
|
||||
t.Fatalf("expected set")
|
||||
}
|
||||
p2, since2 := updateSustained(now.Add(10*time.Second), p, since, model.PriorityP2)
|
||||
if p2 != model.PriorityP2 || !since2.Equal(since) {
|
||||
t.Fatalf("expected unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// MemCollector checks MemAvailable and swap pressure from /proc/meminfo.
|
||||
//
|
||||
// Thresholds (PLAN.md):
|
||||
// Memory (MemAvailable as % of MemTotal):
|
||||
// - P2 if <= 15% sustained 60s
|
||||
// - P1 if <= 10% sustained 60s
|
||||
// - P0 if <= 5% sustained 30s
|
||||
//
|
||||
// Swap pressure (only if RAM is also tight):
|
||||
// - P1 if swap used >= 50% AND MemAvailable <= 10% sustained 60s
|
||||
// - P0 if swap used >= 80% AND MemAvailable <= 5% sustained 30s
|
||||
//
|
||||
// Emits up to two issues:
|
||||
// - host:mem:available
|
||||
// - host:mem:swap
|
||||
//
|
||||
// NOTE: Linux-specific.
|
||||
// Thread-safe: Collect() can be called concurrently.
|
||||
type MemCollector struct {
|
||||
interval time.Duration
|
||||
|
||||
now func() time.Time
|
||||
readFile func(string) ([]byte, error)
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
memPri model.Priority
|
||||
memSince time.Time
|
||||
|
||||
swapPri model.Priority
|
||||
swapSince time.Time
|
||||
}
|
||||
|
||||
func NewMemCollector() *MemCollector {
|
||||
return &MemCollector{
|
||||
interval: 5 * time.Second,
|
||||
now: time.Now,
|
||||
readFile: os.ReadFile,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MemCollector) Name() string { return "host:mem" }
|
||||
|
||||
func (c *MemCollector) Interval() time.Duration {
|
||||
if c.interval <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *MemCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
|
||||
now := c.now()
|
||||
b, err := c.readFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "failed reading /proc/meminfo"}, err
|
||||
}
|
||||
|
||||
mi := parseProcMeminfo(string(b))
|
||||
memTotalKB, okT := mi["MemTotal"]
|
||||
memAvailKB, okA := mi["MemAvailable"]
|
||||
if !okT || !okA || memTotalKB <= 0 {
|
||||
return nil, collectors.Status{Health: collectors.HealthDegraded, Message: "missing MemTotal/MemAvailable"}, nil
|
||||
}
|
||||
|
||||
memAvailPct := (float64(memAvailKB) / float64(memTotalKB)) * 100.0
|
||||
|
||||
desiredMemPri, memWindow := desiredMemPriority(memAvailPct)
|
||||
c.mu.Lock()
|
||||
c.memPri, c.memSince = updateSustained(now, c.memPri, c.memSince, desiredMemPri)
|
||||
memPri, memSince := c.memPri, c.memSince
|
||||
c.mu.Unlock()
|
||||
|
||||
issues := make([]model.Issue, 0, 2)
|
||||
if memPri != "" && !memSince.IsZero() && now.Sub(memSince) >= memWindow {
|
||||
issues = append(issues, model.Issue{
|
||||
ID: "host:mem:available",
|
||||
Category: model.CategoryMemory,
|
||||
Priority: memPri,
|
||||
Title: "Low available memory",
|
||||
Details: "MemAvailable is low and has remained low for a sustained period.",
|
||||
Evidence: map[string]string{
|
||||
"mem_available_kb": strconv.FormatInt(memAvailKB, 10),
|
||||
"mem_total_kb": strconv.FormatInt(memTotalKB, 10),
|
||||
"mem_available_pct": fmt.Sprintf("%.1f", memAvailPct),
|
||||
},
|
||||
SuggestedFix: "Identify memory hogs:\n free -h\n ps aux --sort=-rss | head\nConsider restarting runaway processes or adding RAM.",
|
||||
})
|
||||
}
|
||||
|
||||
swapTotalKB, okST := mi["SwapTotal"]
|
||||
swapFreeKB, okSF := mi["SwapFree"]
|
||||
swapUsedPct := 0.0
|
||||
if okST && okSF && swapTotalKB > 0 {
|
||||
swapUsedKB := swapTotalKB - swapFreeKB
|
||||
swapUsedPct = (float64(swapUsedKB) / float64(swapTotalKB)) * 100.0
|
||||
}
|
||||
|
||||
desiredSwapPri, swapWindow := desiredSwapPriority(memAvailPct, swapTotalKB, swapUsedPct)
|
||||
c.mu.Lock()
|
||||
c.swapPri, c.swapSince = updateSustained(now, c.swapPri, c.swapSince, desiredSwapPri)
|
||||
swapPri, swapSince := c.swapPri, c.swapSince
|
||||
c.mu.Unlock()
|
||||
if swapPri != "" && !swapSince.IsZero() && now.Sub(swapSince) >= swapWindow {
|
||||
issues = append(issues, model.Issue{
|
||||
ID: "host:mem:swap",
|
||||
Category: model.CategoryMemory,
|
||||
Priority: swapPri,
|
||||
Title: "High swap usage with low RAM",
|
||||
Details: "Swap usage is high while available RAM is also low, indicating memory pressure.",
|
||||
Evidence: map[string]string{
|
||||
"swap_used_pct": fmt.Sprintf("%.1f", swapUsedPct),
|
||||
"swap_total_kb": strconv.FormatInt(swapTotalKB, 10),
|
||||
"mem_available_pct": fmt.Sprintf("%.1f", memAvailPct),
|
||||
},
|
||||
SuggestedFix: "Find swapping processes:\n vmstat 1\n smem -r 2>/dev/null || true\nConsider reducing memory usage or increasing RAM/swap.",
|
||||
})
|
||||
}
|
||||
|
||||
return issues, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
func parseProcMeminfo(content string) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
s := bufio.NewScanner(strings.NewReader(content))
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Example: "MemAvailable: 12345 kB"
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
v, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func desiredMemPriority(memAvailPct float64) (model.Priority, time.Duration) {
|
||||
switch {
|
||||
case memAvailPct <= 5.0:
|
||||
return model.PriorityP0, 30 * time.Second
|
||||
case memAvailPct <= 10.0:
|
||||
return model.PriorityP1, 60 * time.Second
|
||||
case memAvailPct <= 15.0:
|
||||
return model.PriorityP2, 60 * time.Second
|
||||
default:
|
||||
return "", 0
|
||||
}
|
||||
}
|
||||
|
||||
func desiredSwapPriority(memAvailPct float64, swapTotalKB int64, swapUsedPct float64) (model.Priority, time.Duration) {
|
||||
if swapTotalKB <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
// Only alert on swap when RAM is also tight.
|
||||
switch {
|
||||
case swapUsedPct >= 80.0 && memAvailPct <= 5.0:
|
||||
return model.PriorityP0, 30 * time.Second
|
||||
case swapUsedPct >= 50.0 && memAvailPct <= 10.0:
|
||||
return model.PriorityP1, 60 * time.Second
|
||||
default:
|
||||
return "", 0
|
||||
}
|
||||
}
|
||||
|
||||
// updateSustained updates current severity and its since timestamp.
|
||||
// If desired is empty, it clears the state.
|
||||
func updateSustained(now time.Time, current model.Priority, since time.Time, desired model.Priority) (model.Priority, time.Time) {
|
||||
if desired == "" {
|
||||
return "", time.Time{}
|
||||
}
|
||||
if current != desired || since.IsZero() {
|
||||
return desired, now
|
||||
}
|
||||
return current, since
|
||||
}
|
||||
|
||||
var _ collectors.Collector = (*MemCollector)(nil)
|
||||
@@ -0,0 +1,83 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
func TestParseProcMeminfo(t *testing.T) {
|
||||
in := "MemTotal: 8000000 kB\nMemAvailable: 800000 kB\nSwapTotal: 2000000 kB\nSwapFree: 500000 kB\n"
|
||||
m := parseProcMeminfo(in)
|
||||
if m["MemTotal"] != 8000000 {
|
||||
t.Fatalf("MemTotal mismatch: %d", m["MemTotal"])
|
||||
}
|
||||
if m["MemAvailable"] != 800000 {
|
||||
t.Fatalf("MemAvailable mismatch: %d", m["MemAvailable"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredMemPriority(t *testing.T) {
|
||||
p, w := desiredMemPriority(16.0)
|
||||
if p != "" || w != 0 {
|
||||
t.Fatalf("expected none")
|
||||
}
|
||||
|
||||
p, w = desiredMemPriority(15.0)
|
||||
if p != model.PriorityP2 || w != 60*time.Second {
|
||||
t.Fatalf("expected P2/60s got %v/%v", p, w)
|
||||
}
|
||||
p, w = desiredMemPriority(10.0)
|
||||
if p != model.PriorityP1 {
|
||||
t.Fatalf("expected P1 got %v", p)
|
||||
}
|
||||
p, w = desiredMemPriority(5.0)
|
||||
if p != model.PriorityP0 || w != 30*time.Second {
|
||||
t.Fatalf("expected P0/30s got %v/%v", p, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredSwapPriority(t *testing.T) {
|
||||
// No swap configured.
|
||||
p, _ := desiredSwapPriority(4.0, 0, 90.0)
|
||||
if p != "" {
|
||||
t.Fatalf("expected none when SwapTotal=0")
|
||||
}
|
||||
|
||||
p, w := desiredSwapPriority(4.0, 1000, 80.0)
|
||||
if p != model.PriorityP0 || w != 30*time.Second {
|
||||
t.Fatalf("expected P0/30s got %v/%v", p, w)
|
||||
}
|
||||
|
||||
p, w = desiredSwapPriority(9.9, 1000, 50.0)
|
||||
if p != model.PriorityP1 || w != 60*time.Second {
|
||||
t.Fatalf("expected P1/60s got %v/%v", p, w)
|
||||
}
|
||||
|
||||
// Swap high but RAM not tight => no issue.
|
||||
p, _ = desiredSwapPriority(20.0, 1000, 90.0)
|
||||
if p != "" {
|
||||
t.Fatalf("expected none when RAM not tight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSustained(t *testing.T) {
|
||||
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
p, since := updateSustained(now, "", time.Time{}, model.PriorityP1)
|
||||
if p != model.PriorityP1 || !since.Equal(now) {
|
||||
t.Fatalf("expected set to P1 at now")
|
||||
}
|
||||
p2, since2 := updateSustained(now.Add(1*time.Second), p, since, model.PriorityP1)
|
||||
if p2 != model.PriorityP1 || !since2.Equal(since) {
|
||||
t.Fatalf("expected unchanged since")
|
||||
}
|
||||
p3, since3 := updateSustained(now.Add(2*time.Second), p2, since2, model.PriorityP0)
|
||||
if p3 != model.PriorityP0 || !since3.Equal(now.Add(2*time.Second)) {
|
||||
t.Fatalf("expected reset on priority change")
|
||||
}
|
||||
p4, since4 := updateSustained(now.Add(3*time.Second), p3, since3, "")
|
||||
if p4 != "" || !since4.IsZero() {
|
||||
t.Fatalf("expected cleared")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// NetCollector checks for missing default route while at least one non-loopback
|
||||
// interface is up.
|
||||
//
|
||||
// Rule (PLAN.md):
|
||||
// - P1 if no default route AND any non-loopback interface is UP.
|
||||
//
|
||||
// Discovery:
|
||||
// - Default route from /proc/net/route
|
||||
// - Interface UP from /sys/class/net/*/operstate
|
||||
//
|
||||
// NOTE: Linux-specific.
|
||||
type NetCollector struct {
|
||||
interval time.Duration
|
||||
|
||||
readFile func(string) ([]byte, error)
|
||||
glob func(string) ([]string, error)
|
||||
}
|
||||
|
||||
func NewNetCollector() *NetCollector {
|
||||
return &NetCollector{
|
||||
interval: 5 * time.Second,
|
||||
readFile: os.ReadFile,
|
||||
glob: filepath.Glob,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *NetCollector) Name() string { return "host:net" }
|
||||
|
||||
func (c *NetCollector) Interval() time.Duration {
|
||||
if c.interval <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *NetCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
|
||||
routeBytes, err := c.readFile("/proc/net/route")
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "failed reading /proc/net/route"}, err
|
||||
}
|
||||
|
||||
hasDefault := hasDefaultRoute(string(routeBytes))
|
||||
|
||||
paths, err := c.glob("/sys/class/net/*/operstate")
|
||||
if err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "failed listing /sys/class/net"}, err
|
||||
}
|
||||
upIfaces := make([]string, 0, 2)
|
||||
for _, p := range paths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
b, err := c.readFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
iface := filepath.Base(filepath.Dir(p))
|
||||
if iface == "lo" {
|
||||
continue
|
||||
}
|
||||
state := strings.TrimSpace(string(b))
|
||||
if isIfaceUp(state) {
|
||||
upIfaces = append(upIfaces, iface)
|
||||
}
|
||||
}
|
||||
|
||||
if hasDefault || len(upIfaces) == 0 {
|
||||
return nil, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
iss := model.Issue{
|
||||
ID: "host:net:default-route-missing",
|
||||
Category: model.CategoryNetwork,
|
||||
Priority: model.PriorityP1,
|
||||
Title: "No default route",
|
||||
Details: "At least one network interface is up, but no default route is present.",
|
||||
Evidence: map[string]string{
|
||||
"up_ifaces": strings.Join(upIfaces, ","),
|
||||
},
|
||||
SuggestedFix: "Check routing and link state:\n ip route\n ip link\n nmcli dev status\nIf on Wi-Fi, reconnect; if on VPN, verify tunnel routes.",
|
||||
}
|
||||
return []model.Issue{iss}, collectors.OKStatus(), nil
|
||||
}
|
||||
|
||||
func hasDefaultRoute(procNetRoute string) bool {
|
||||
// /proc/net/route header:
|
||||
// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
// Default route has Destination == 00000000.
|
||||
s := bufio.NewScanner(strings.NewReader(procNetRoute))
|
||||
first := true
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if first {
|
||||
first = false
|
||||
// skip header if present
|
||||
if strings.HasPrefix(line, "Iface") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if fields[1] == "00000000" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isIfaceUp(operstate string) bool {
|
||||
// Linux operstate values include: up, down, unknown, dormant, lowerlayerdown.
|
||||
s := strings.ToLower(strings.TrimSpace(operstate))
|
||||
return s == "up" || s == "unknown"
|
||||
}
|
||||
|
||||
var _ collectors.Collector = (*NetCollector)(nil)
|
||||
@@ -0,0 +1,28 @@
|
||||
package host
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasDefaultRoute(t *testing.T) {
|
||||
in := "Iface\tDestination\tGateway\tFlags\n" +
|
||||
"eth0\t00000000\t0102A8C0\t0003\n"
|
||||
if !hasDefaultRoute(in) {
|
||||
t.Fatalf("expected default route")
|
||||
}
|
||||
in2 := "Iface Destination Gateway Flags\n" +
|
||||
"eth0 0010A8C0 00000000 0001\n"
|
||||
if hasDefaultRoute(in2) {
|
||||
t.Fatalf("expected no default route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIfaceUp(t *testing.T) {
|
||||
if !isIfaceUp("up\n") {
|
||||
t.Fatalf("expected true")
|
||||
}
|
||||
if !isIfaceUp("unknown") {
|
||||
t.Fatalf("expected true for unknown")
|
||||
}
|
||||
if isIfaceUp("down") {
|
||||
t.Fatalf("expected false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// ClientFromCurrentContext creates a Kubernetes client-go Clientset using the
|
||||
// user's kubeconfig current context.
|
||||
//
|
||||
// It is a pure helper (no global state) so it can be used by collectors and
|
||||
// unit tests (with temporary kubeconfig files).
|
||||
func ClientFromCurrentContext() (*kubernetes.Clientset, *rest.Config, error) {
|
||||
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||
|
||||
// Respect KUBECONFIG semantics (it may be a path list).
|
||||
if p := os.Getenv("KUBECONFIG"); p != "" {
|
||||
if list := filepath.SplitList(p); len(list) > 1 {
|
||||
loadingRules.ExplicitPath = ""
|
||||
loadingRules.Precedence = list
|
||||
} else {
|
||||
loadingRules.ExplicitPath = p
|
||||
}
|
||||
}
|
||||
|
||||
cfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
|
||||
restCfg, err := cfg.ClientConfig()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Ensure HTTP client timeouts are bounded. LIST fallback uses its own context
|
||||
// timeouts, but this provides a safety net.
|
||||
if restCfg.Timeout <= 0 {
|
||||
restCfg.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
cs, err := kubernetes.NewForConfig(restCfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cs, restCfg, nil
|
||||
}
|
||||
|
||||
func defaultKubeconfigPath() string {
|
||||
// This helper is used only for existence checks / UI messages. Client loading
|
||||
// should use client-go's default loading rules.
|
||||
if p := os.Getenv("KUBECONFIG"); p != "" {
|
||||
// If KUBECONFIG is a list, return the first entry for display.
|
||||
if list := filepath.SplitList(p); len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
h, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h, ".kube", "config")
|
||||
}
|
||||
|
||||
// Ping performs a lightweight API call to determine if the cluster is reachable
|
||||
// and authentication works.
|
||||
func Ping(ctx context.Context, cs kubernetes.Interface) error {
|
||||
if cs == nil {
|
||||
return errors.New("nil kubernetes client")
|
||||
}
|
||||
_, err := cs.Discovery().ServerVersion()
|
||||
if err != nil {
|
||||
// Treat authn/authz errors separately so callers can decide whether to
|
||||
// surface "unreachable" vs "insufficient credentials".
|
||||
if apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err) {
|
||||
return fmt.Errorf("discovery auth: %w", err)
|
||||
}
|
||||
return fmt.Errorf("discovery server version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/client-go/informers"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
appslisters "k8s.io/client-go/listers/apps/v1"
|
||||
corelisters "k8s.io/client-go/listers/core/v1"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// Collector is the ControlTower Kubernetes collector.
|
||||
//
|
||||
// It uses client-go informers (LIST+WATCH with local caches) against the user's
|
||||
// kubeconfig current context, across all namespaces.
|
||||
//
|
||||
// Degradation behavior:
|
||||
// - If WATCH fails repeatedly, it falls back to polling LIST and emits a P1
|
||||
// "degraded to polling" issue.
|
||||
// - While in polling mode, it periodically attempts to recover back to watches.
|
||||
// - If the cluster is unreachable, it emits a P0 only after 10s continuous failure.
|
||||
// - If RBAC forbids list/watch for a resource, it emits a single P2 issue per
|
||||
// inaccessible resource and continues for accessible resources.
|
||||
//
|
||||
// Noise control:
|
||||
// - Rollups group by (namespace, reason, kind) when group size >= 20.
|
||||
// - Cap max issues to 200 after rollups.
|
||||
//
|
||||
// Instantiate with NewCollector().
|
||||
type Collector struct {
|
||||
interval time.Duration
|
||||
|
||||
unreachableGrace time.Duration
|
||||
pendingGrace time.Duration
|
||||
workloadGrace time.Duration
|
||||
crashLoopThresh int
|
||||
|
||||
rollupThreshold int
|
||||
maxIssues int
|
||||
|
||||
watchFailureThreshold int
|
||||
watchFailureWindow time.Duration
|
||||
pollRecoverEvery time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
syncWG sync.WaitGroup
|
||||
|
||||
client kubernetes.Interface
|
||||
|
||||
factory informers.SharedInformerFactory
|
||||
stopCh chan struct{}
|
||||
started bool
|
||||
syncedFns []cache.InformerSynced
|
||||
|
||||
podsLister corelisters.PodLister
|
||||
nodesLister corelisters.NodeLister
|
||||
eventsLister corelisters.EventLister
|
||||
deployLister appslisters.DeploymentLister
|
||||
statefulSetLister appslisters.StatefulSetLister
|
||||
daemonSetLister appslisters.DaemonSetLister
|
||||
|
||||
// polling indicates we have degraded from informers to list polling.
|
||||
polling bool
|
||||
pollSince time.Time
|
||||
lastPollRecoverAttempt time.Time
|
||||
|
||||
watchFailWindowStart time.Time
|
||||
watchFailCount int
|
||||
|
||||
// rbacDenied is keyed by resource name ("pods", "nodes", ...).
|
||||
rbacDenied map[string]error
|
||||
|
||||
unreach *unreachableTracker
|
||||
|
||||
lastSuccess time.Time
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
c := &Collector{
|
||||
interval: 2 * time.Second,
|
||||
unreachableGrace: 10 * time.Second,
|
||||
pendingGrace: 120 * time.Second,
|
||||
workloadGrace: 180 * time.Second,
|
||||
crashLoopThresh: 5,
|
||||
rollupThreshold: 20,
|
||||
maxIssues: 200,
|
||||
watchFailureThreshold: 5,
|
||||
watchFailureWindow: 30 * time.Second,
|
||||
pollRecoverEvery: 30 * time.Second,
|
||||
rbacDenied: map[string]error{},
|
||||
}
|
||||
c.unreach = newUnreachableTracker(c.unreachableGrace)
|
||||
return c
|
||||
}
|
||||
|
||||
var _ collectors.Collector = (*Collector)(nil)
|
||||
|
||||
func (c *Collector) Name() string { return "k8s" }
|
||||
|
||||
func (c *Collector) Interval() time.Duration {
|
||||
if c.interval <= 0 {
|
||||
return 2 * time.Second
|
||||
}
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *Collector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
now := time.Now()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "canceled"}, err
|
||||
}
|
||||
|
||||
// If kubeconfig doesn't exist, treat Kubernetes as "disabled".
|
||||
if !kubeconfigExists() {
|
||||
return nil, collectors.Status{Health: collectors.HealthDegraded, Message: "kubeconfig not found"}, nil
|
||||
}
|
||||
|
||||
if err := c.ensureClient(); err != nil {
|
||||
c.unreach.observeFailure(now, err)
|
||||
if c.unreach.shouldEmit(now) {
|
||||
iss := stampIssueTimes(now, unreachableIssue(err))
|
||||
return []model.Issue{iss}, collectors.Status{Health: collectors.HealthError, Message: "unreachable"}, nil
|
||||
}
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "k8s client init failed (grace)"}, nil
|
||||
}
|
||||
|
||||
// Connectivity/auth check with grace.
|
||||
if err := Ping(ctx, c.client); err != nil {
|
||||
c.unreach.observeFailure(now, err)
|
||||
if c.unreach.shouldEmit(now) {
|
||||
iss := stampIssueTimes(now, unreachableIssue(err))
|
||||
return []model.Issue{iss}, collectors.Status{Health: collectors.HealthError, Message: "unreachable"}, nil
|
||||
}
|
||||
return nil, collectors.Status{Health: collectors.HealthError, Message: "k8s unreachable (grace)"}, nil
|
||||
}
|
||||
c.unreach.observeSuccess()
|
||||
c.lastSuccess = now
|
||||
|
||||
// Prefer informers unless currently degraded to polling.
|
||||
if c.isPolling() {
|
||||
c.maybeRecoverInformers(ctx, now)
|
||||
}
|
||||
if !c.isPolling() {
|
||||
_ = c.ensureInformers(ctx)
|
||||
}
|
||||
|
||||
issues := make([]model.Issue, 0, 64)
|
||||
issues = append(issues, c.rbacIssues()...)
|
||||
|
||||
st := collectors.Status{Health: collectors.HealthOK, LastSuccess: c.lastSuccess}
|
||||
|
||||
if c.isPolling() {
|
||||
st.Health = collectors.HealthDegraded
|
||||
st.Message = "degraded to polling"
|
||||
issues = append(issues, stampIssueTimes(now, pollingDegradedIssue()))
|
||||
issues = append(issues, c.collectByPolling(ctx, now)...)
|
||||
} else {
|
||||
// If caches aren't ready, use polling for this tick only.
|
||||
if !c.cachesSyncedQuick(ctx) {
|
||||
st.Health = collectors.HealthDegraded
|
||||
st.Message = "waiting for informer cache; used list"
|
||||
issues = append(issues, c.collectByPolling(ctx, now)...)
|
||||
} else {
|
||||
issues = append(issues, c.collectFromCaches(now)...)
|
||||
if len(c.snapshotRBACDenied()) > 0 {
|
||||
st.Health = collectors.HealthDegraded
|
||||
st.Message = "partial RBAC access"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set timestamps, roll up and cap.
|
||||
for i := range issues {
|
||||
issues[i] = stampIssueTimes(now, issues[i])
|
||||
}
|
||||
issues = Rollup(issues, c.rollupThreshold, 5)
|
||||
model.SortIssuesDefault(issues)
|
||||
issues = CapIssues(issues, c.maxIssues)
|
||||
|
||||
return issues, st, nil
|
||||
}
|
||||
|
||||
func (c *Collector) ensureClient() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.client != nil {
|
||||
return nil
|
||||
}
|
||||
cs, _, err := ClientFromCurrentContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.client = cs
|
||||
return nil
|
||||
}
|
||||
|
||||
func kubeconfigExists() bool {
|
||||
if p := os.Getenv("KUBECONFIG"); p != "" {
|
||||
for _, fp := range filepath.SplitList(p) {
|
||||
if fp == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(fp); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
p := defaultKubeconfigPath()
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Collector) ensureInformers(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.started || c.polling {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
client := c.client
|
||||
c.mu.Unlock()
|
||||
if client == nil {
|
||||
return fmt.Errorf("nil kubernetes client")
|
||||
}
|
||||
|
||||
// RBAC preflight before we even construct informers (so we can skip forbidden ones).
|
||||
c.preflightRBAC(ctx, client)
|
||||
|
||||
factory := informers.NewSharedInformerFactory(client, 0)
|
||||
|
||||
var (
|
||||
podsInf cache.SharedIndexInformer
|
||||
nodesInf cache.SharedIndexInformer
|
||||
evsInf cache.SharedIndexInformer
|
||||
depInf cache.SharedIndexInformer
|
||||
stsInf cache.SharedIndexInformer
|
||||
dsInf cache.SharedIndexInformer
|
||||
)
|
||||
|
||||
if !c.isRBACDenied("pods") {
|
||||
i := factory.Core().V1().Pods()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("pods", err) })
|
||||
c.mu.Lock()
|
||||
c.podsLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
podsInf = i.Informer()
|
||||
}
|
||||
if !c.isRBACDenied("nodes") {
|
||||
i := factory.Core().V1().Nodes()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("nodes", err) })
|
||||
c.mu.Lock()
|
||||
c.nodesLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
nodesInf = i.Informer()
|
||||
}
|
||||
if !c.isRBACDenied("events") {
|
||||
i := factory.Core().V1().Events()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("events", err) })
|
||||
c.mu.Lock()
|
||||
c.eventsLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
evsInf = i.Informer()
|
||||
}
|
||||
if !c.isRBACDenied("deployments") {
|
||||
i := factory.Apps().V1().Deployments()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("deployments", err) })
|
||||
c.mu.Lock()
|
||||
c.deployLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
depInf = i.Informer()
|
||||
}
|
||||
if !c.isRBACDenied("statefulsets") {
|
||||
i := factory.Apps().V1().StatefulSets()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("statefulsets", err) })
|
||||
c.mu.Lock()
|
||||
c.statefulSetLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
stsInf = i.Informer()
|
||||
}
|
||||
if !c.isRBACDenied("daemonsets") {
|
||||
i := factory.Apps().V1().DaemonSets()
|
||||
i.Informer().SetWatchErrorHandler(func(_ *cache.Reflector, err error) { c.recordWatchError("daemonsets", err) })
|
||||
c.mu.Lock()
|
||||
c.daemonSetLister = i.Lister()
|
||||
c.mu.Unlock()
|
||||
dsInf = i.Informer()
|
||||
}
|
||||
|
||||
synced := make([]cache.InformerSynced, 0, 6)
|
||||
if podsInf != nil {
|
||||
synced = append(synced, podsInf.HasSynced)
|
||||
}
|
||||
if nodesInf != nil {
|
||||
synced = append(synced, nodesInf.HasSynced)
|
||||
}
|
||||
if evsInf != nil {
|
||||
synced = append(synced, evsInf.HasSynced)
|
||||
}
|
||||
if depInf != nil {
|
||||
synced = append(synced, depInf.HasSynced)
|
||||
}
|
||||
if stsInf != nil {
|
||||
synced = append(synced, stsInf.HasSynced)
|
||||
}
|
||||
if dsInf != nil {
|
||||
synced = append(synced, dsInf.HasSynced)
|
||||
}
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
|
||||
c.mu.Lock()
|
||||
c.factory = factory
|
||||
c.stopCh = stopCh
|
||||
c.started = true
|
||||
c.syncedFns = synced
|
||||
c.mu.Unlock()
|
||||
|
||||
factory.Start(stopCh)
|
||||
|
||||
c.syncWG.Add(1)
|
||||
go func() {
|
||||
defer c.syncWG.Done()
|
||||
syncCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if ok := cache.WaitForCacheSync(syncCtx.Done(), synced...); !ok {
|
||||
fmt.Printf("k8s: informer cache sync failed or timed out\n")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) maybeRecoverInformers(ctx context.Context, now time.Time) {
|
||||
c.mu.Lock()
|
||||
interval := c.pollRecoverEvery
|
||||
last := c.lastPollRecoverAttempt
|
||||
c.mu.Unlock()
|
||||
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
if !last.IsZero() && now.Sub(last) < interval {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.lastPollRecoverAttempt = now
|
||||
c.mu.Unlock()
|
||||
|
||||
// Only attempt if connectivity is OK (already pinged successfully in Collect).
|
||||
// Reset watch failure counters and exit polling; subsequent Collect will ensureInformers.
|
||||
c.mu.Lock()
|
||||
c.polling = false
|
||||
c.pollSince = time.Time{}
|
||||
c.watchFailWindowStart = time.Time{}
|
||||
c.watchFailCount = 0
|
||||
c.mu.Unlock()
|
||||
|
||||
_ = c.ensureInformers(ctx)
|
||||
}
|
||||
|
||||
func (c *Collector) preflightRBAC(ctx context.Context, client kubernetes.Interface) {
|
||||
shortCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
probe := func(resource string, f func(context.Context) error) {
|
||||
if err := f(shortCtx); err != nil {
|
||||
if apierrors.IsForbidden(err) {
|
||||
c.noteRBAC(resource, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probe("nodes", func(ctx context.Context) error {
|
||||
_, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
probe("pods", func(ctx context.Context) error {
|
||||
_, err := client.CoreV1().Pods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
probe("deployments", func(ctx context.Context) error {
|
||||
_, err := client.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
probe("statefulsets", func(ctx context.Context) error {
|
||||
_, err := client.AppsV1().StatefulSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
probe("daemonsets", func(ctx context.Context) error {
|
||||
_, err := client.AppsV1().DaemonSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
probe("events", func(ctx context.Context) error {
|
||||
_, err := client.CoreV1().Events(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Collector) noteRBAC(resource string, err error) {
|
||||
if err == nil || !apierrors.IsForbidden(err) {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := c.rbacDenied[resource]; ok {
|
||||
return
|
||||
}
|
||||
c.rbacDenied[resource] = err
|
||||
}
|
||||
|
||||
func (c *Collector) isRBACDenied(resource string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, ok := c.rbacDenied[resource]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *Collector) snapshotRBACDenied() map[string]error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make(map[string]error, len(c.rbacDenied))
|
||||
for k, v := range c.rbacDenied {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Collector) recordWatchError(resource string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if apierrors.IsForbidden(err) {
|
||||
c.noteRBAC(resource, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.polling {
|
||||
return
|
||||
}
|
||||
if c.watchFailWindowStart.IsZero() || now.Sub(c.watchFailWindowStart) > c.watchFailureWindow {
|
||||
c.watchFailWindowStart = now
|
||||
c.watchFailCount = 0
|
||||
}
|
||||
c.watchFailCount++
|
||||
if c.watchFailCount >= c.watchFailureThreshold {
|
||||
c.polling = true
|
||||
c.pollSince = now
|
||||
if c.stopCh != nil {
|
||||
close(c.stopCh)
|
||||
c.stopCh = nil
|
||||
}
|
||||
c.started = false
|
||||
c.factory = nil
|
||||
c.syncedFns = nil
|
||||
c.syncWG.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) cachesSyncedQuick(ctx context.Context) bool {
|
||||
c.mu.Lock()
|
||||
synced := append([]cache.InformerSynced(nil), c.syncedFns...)
|
||||
c.mu.Unlock()
|
||||
if len(synced) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
syncCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||
defer cancel()
|
||||
return cache.WaitForCacheSync(syncCtx.Done(), synced...)
|
||||
}
|
||||
|
||||
func (c *Collector) collectFromCaches(now time.Time) []model.Issue {
|
||||
c.mu.Lock()
|
||||
podsLister := c.podsLister
|
||||
nodesLister := c.nodesLister
|
||||
eventsLister := c.eventsLister
|
||||
deployLister := c.deployLister
|
||||
stsLister := c.statefulSetLister
|
||||
dsLister := c.daemonSetLister
|
||||
denied := make(map[string]error, len(c.rbacDenied))
|
||||
for k, v := range c.rbacDenied {
|
||||
denied[k] = v
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
issues := make([]model.Issue, 0, 64)
|
||||
sel := labels.Everything()
|
||||
|
||||
if _, ok := denied["nodes"]; !ok && nodesLister != nil {
|
||||
if list, err := nodesLister.List(sel); err == nil {
|
||||
nodes := make([]*corev1.Node, 0, len(list))
|
||||
for i := range list {
|
||||
nodes = append(nodes, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromNodes(nodes)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["pods"]; !ok && podsLister != nil {
|
||||
if list, err := podsLister.List(sel); err == nil {
|
||||
pods := make([]*corev1.Pod, 0, len(list))
|
||||
for i := range list {
|
||||
pods = append(pods, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromPods(pods, now, c.pendingGrace, c.crashLoopThresh)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["deployments"]; !ok && deployLister != nil {
|
||||
if list, err := deployLister.List(sel); err == nil {
|
||||
deps := make([]*appsv1.Deployment, 0, len(list))
|
||||
for i := range list {
|
||||
deps = append(deps, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromDeployments(deps, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
if _, ok := denied["statefulsets"]; !ok && stsLister != nil {
|
||||
if list, err := stsLister.List(sel); err == nil {
|
||||
sts := make([]*appsv1.StatefulSet, 0, len(list))
|
||||
for i := range list {
|
||||
sts = append(sts, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromStatefulSets(sts, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
if _, ok := denied["daemonsets"]; !ok && dsLister != nil {
|
||||
if list, err := dsLister.List(sel); err == nil {
|
||||
dss := make([]*appsv1.DaemonSet, 0, len(list))
|
||||
for i := range list {
|
||||
dss = append(dss, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromDaemonSets(dss, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["events"]; !ok && eventsLister != nil {
|
||||
if list, err := eventsLister.List(sel); err == nil {
|
||||
es := make([]*corev1.Event, 0, len(list))
|
||||
for i := range list {
|
||||
es = append(es, list[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromEvents(es, now)...)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func (c *Collector) collectByPolling(ctx context.Context, now time.Time) []model.Issue {
|
||||
c.mu.Lock()
|
||||
client := c.client
|
||||
denied := make(map[string]error, len(c.rbacDenied))
|
||||
for k, v := range c.rbacDenied {
|
||||
denied[k] = v
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
issues := make([]model.Issue, 0, 64)
|
||||
|
||||
if _, ok := denied["nodes"]; !ok {
|
||||
if nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("nodes", err)
|
||||
} else {
|
||||
list := make([]*corev1.Node, 0, len(nodes.Items))
|
||||
for i := range nodes.Items {
|
||||
list = append(list, &nodes.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromNodes(list)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["pods"]; !ok {
|
||||
if pods, err := client.CoreV1().Pods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("pods", err)
|
||||
} else {
|
||||
list := make([]*corev1.Pod, 0, len(pods.Items))
|
||||
for i := range pods.Items {
|
||||
list = append(list, &pods.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromPods(list, now, c.pendingGrace, c.crashLoopThresh)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["deployments"]; !ok {
|
||||
if deps, err := client.AppsV1().Deployments(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("deployments", err)
|
||||
} else {
|
||||
list := make([]*appsv1.Deployment, 0, len(deps.Items))
|
||||
for i := range deps.Items {
|
||||
list = append(list, &deps.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromDeployments(list, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["statefulsets"]; !ok {
|
||||
if sts, err := client.AppsV1().StatefulSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("statefulsets", err)
|
||||
} else {
|
||||
list := make([]*appsv1.StatefulSet, 0, len(sts.Items))
|
||||
for i := range sts.Items {
|
||||
list = append(list, &sts.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromStatefulSets(list, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["daemonsets"]; !ok {
|
||||
if dss, err := client.AppsV1().DaemonSets(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("daemonsets", err)
|
||||
} else {
|
||||
list := make([]*appsv1.DaemonSet, 0, len(dss.Items))
|
||||
for i := range dss.Items {
|
||||
list = append(list, &dss.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromDaemonSets(list, now, c.workloadGrace)...)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := denied["events"]; !ok {
|
||||
if evs, err := client.CoreV1().Events(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}); err != nil {
|
||||
c.noteRBAC("events", err)
|
||||
} else {
|
||||
list := make([]*corev1.Event, 0, len(evs.Items))
|
||||
for i := range evs.Items {
|
||||
list = append(list, &evs.Items[i])
|
||||
}
|
||||
issues = append(issues, IssuesFromEvents(list, now)...)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
func (c *Collector) rbacIssues() []model.Issue {
|
||||
denied := c.snapshotRBACDenied()
|
||||
keys := make([]string, 0, len(denied))
|
||||
for k := range denied {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make([]model.Issue, 0, len(keys))
|
||||
for _, res := range keys {
|
||||
err := denied[res]
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:rbac:%s", res),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP2,
|
||||
Title: fmt.Sprintf("Insufficient RBAC: list/watch %s", res),
|
||||
Details: fmt.Sprintf("Current context cannot access %s (forbidden). %s", res, sanitizeError(err)),
|
||||
Evidence: map[string]string{
|
||||
"kind": "Cluster",
|
||||
"reason": "RBAC",
|
||||
"namespace": "",
|
||||
"resource": res,
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl auth can-i list %s --all-namespaces", res),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pollingDegradedIssue() model.Issue {
|
||||
return model.Issue{
|
||||
ID: "k8s:cluster:polling",
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: "Kubernetes degraded: polling (watch failing)",
|
||||
Details: "Kubernetes watches have failed repeatedly; collector switched to LIST polling. Data may be less real-time and API load is higher.",
|
||||
Evidence: map[string]string{
|
||||
"kind": "Cluster",
|
||||
"reason": "DegradedPolling",
|
||||
"namespace": "",
|
||||
},
|
||||
SuggestedFix: "Check API server / network stability and RBAC; ensure watch endpoints are reachable.",
|
||||
}
|
||||
}
|
||||
|
||||
func stampIssueTimes(now time.Time, iss model.Issue) model.Issue {
|
||||
iss.LastSeen = now
|
||||
if iss.FirstSeen.IsZero() {
|
||||
iss.FirstSeen = now
|
||||
}
|
||||
return iss
|
||||
}
|
||||
|
||||
func (c *Collector) isPolling() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.polling
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
var warningEventReasons = map[string]struct{}{
|
||||
"FailedScheduling": {},
|
||||
"FailedMount": {},
|
||||
"BackOff": {},
|
||||
"Unhealthy": {},
|
||||
"OOMKilling": {},
|
||||
"FailedPull": {},
|
||||
"Forbidden": {},
|
||||
"ErrImagePull": {},
|
||||
"ImagePullBackOff": {},
|
||||
}
|
||||
|
||||
// IssuesFromEvents applies the PLAN.md Event rules.
|
||||
//
|
||||
// Dedup by (object UID, reason). For v1 Events, this is approximated by
|
||||
// (involvedObject.uid, reason).
|
||||
func IssuesFromEvents(events []*corev1.Event, now time.Time) []model.Issue {
|
||||
_ = now
|
||||
out := make([]model.Issue, 0, 16)
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, e := range events {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(e.Type) != strings.ToLower(string(corev1.EventTypeWarning)) {
|
||||
continue
|
||||
}
|
||||
if _, ok := warningEventReasons[e.Reason]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
uid := string(e.InvolvedObject.UID)
|
||||
k := uid + ":" + e.Reason
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
|
||||
ns := e.InvolvedObject.Namespace
|
||||
if ns == "" {
|
||||
ns = e.Namespace
|
||||
}
|
||||
|
||||
objKey := e.InvolvedObject.Kind + "/" + e.InvolvedObject.Name
|
||||
title := fmt.Sprintf("K8s Event %s: %s (%s)", e.Reason, objKey, ns)
|
||||
if ns == "" {
|
||||
title = fmt.Sprintf("K8s Event %s: %s", e.Reason, objKey)
|
||||
}
|
||||
|
||||
details := strings.TrimSpace(e.Message)
|
||||
if details == "" {
|
||||
details = "Warning event emitted by Kubernetes."
|
||||
}
|
||||
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:event:%s:%s", uid, e.Reason),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP2,
|
||||
Title: title,
|
||||
Details: details,
|
||||
Evidence: map[string]string{
|
||||
"kind": e.InvolvedObject.Kind,
|
||||
"reason": e.Reason,
|
||||
"namespace": ns,
|
||||
"name": e.InvolvedObject.Name,
|
||||
"uid": uid,
|
||||
},
|
||||
SuggestedFix: suggestedFixForEvent(ns, e.InvolvedObject.Kind, e.InvolvedObject.Name),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func suggestedFixForEvent(ns, kind, name string) string {
|
||||
kindLower := strings.ToLower(kind)
|
||||
if ns != "" {
|
||||
switch kindLower {
|
||||
case "pod":
|
||||
return fmt.Sprintf("kubectl -n %s describe pod %s", ns, name)
|
||||
case "node":
|
||||
return fmt.Sprintf("kubectl describe node %s", name)
|
||||
default:
|
||||
return fmt.Sprintf("kubectl -n %s describe %s %s", ns, kindLower, name)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("kubectl describe %s %s", kindLower, name)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// Placeholder (see rollup_test.go).
|
||||
@@ -0,0 +1,79 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// IssuesFromNodes applies the PLAN.md node rules.
|
||||
//
|
||||
// Pure rule function: does not talk to the API server.
|
||||
func IssuesFromNodes(nodes []*corev1.Node) []model.Issue {
|
||||
out := make([]model.Issue, 0, 8)
|
||||
for _, n := range nodes {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ready / NotReady
|
||||
if cond := findNodeCondition(n, corev1.NodeReady); cond != nil {
|
||||
if cond.Status != corev1.ConditionTrue {
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:node:%s:NotReady", n.Name),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP0,
|
||||
Title: fmt.Sprintf("Node NotReady: %s", n.Name),
|
||||
Details: cond.Message,
|
||||
Evidence: map[string]string{
|
||||
"kind": "Node",
|
||||
"reason": "NotReady",
|
||||
"namespace": "",
|
||||
"node": n.Name,
|
||||
"status": string(cond.Status),
|
||||
},
|
||||
SuggestedFix: "kubectl describe node " + n.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pressure conditions.
|
||||
for _, ctype := range []corev1.NodeConditionType{corev1.NodeMemoryPressure, corev1.NodeDiskPressure, corev1.NodePIDPressure} {
|
||||
if cond := findNodeCondition(n, ctype); cond != nil {
|
||||
if cond.Status == corev1.ConditionTrue {
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:node:%s:%s", n.Name, string(ctype)),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("Node %s: %s", ctype, n.Name),
|
||||
Details: cond.Message,
|
||||
Evidence: map[string]string{
|
||||
"kind": "Node",
|
||||
"reason": string(ctype),
|
||||
"namespace": "",
|
||||
"node": n.Name,
|
||||
"status": string(cond.Status),
|
||||
},
|
||||
SuggestedFix: "kubectl describe node " + n.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findNodeCondition(n *corev1.Node, t corev1.NodeConditionType) *corev1.NodeCondition {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range n.Status.Conditions {
|
||||
c := &n.Status.Conditions[i]
|
||||
if c.Type == t {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// Placeholder (see rollup_test.go).
|
||||
@@ -0,0 +1,169 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// IssuesFromPods applies the PLAN.md pod rules.
|
||||
//
|
||||
// Pure rule function: it does not talk to the API server.
|
||||
func IssuesFromPods(pods []*corev1.Pod, now time.Time, pendingGrace time.Duration, crashLoopRestartThreshold int) []model.Issue {
|
||||
if crashLoopRestartThreshold <= 0 {
|
||||
crashLoopRestartThreshold = 5
|
||||
}
|
||||
if pendingGrace <= 0 {
|
||||
pendingGrace = 120 * time.Second
|
||||
}
|
||||
|
||||
out := make([]model.Issue, 0, 32)
|
||||
for _, p := range pods {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
ns, name := p.Namespace, p.Name
|
||||
|
||||
// Pending for too long.
|
||||
if p.Status.Phase == corev1.PodPending {
|
||||
age := now.Sub(p.CreationTimestamp.Time)
|
||||
if !p.CreationTimestamp.IsZero() && age >= pendingGrace {
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:pod:%s/%s:Pending", ns, name),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("Pod Pending: %s/%s", ns, name),
|
||||
Details: fmt.Sprintf("Pod has been Pending for %s.", age.Truncate(time.Second)),
|
||||
Evidence: map[string]string{
|
||||
"kind": "Pod",
|
||||
"reason": "Pending",
|
||||
"namespace": ns,
|
||||
"pod": name,
|
||||
"phase": string(p.Status.Phase),
|
||||
"node": p.Spec.NodeName,
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe pod %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Container-derived signals.
|
||||
for _, cs := range p.Status.ContainerStatuses {
|
||||
cname := cs.Name
|
||||
restarts := int(cs.RestartCount)
|
||||
|
||||
// CrashLoopBackOff and pull errors are reported via Waiting state.
|
||||
if cs.State.Waiting != nil {
|
||||
reason := cs.State.Waiting.Reason
|
||||
msg := cs.State.Waiting.Message
|
||||
switch reason {
|
||||
case "CrashLoopBackOff":
|
||||
pri := model.PriorityP1
|
||||
if restarts >= crashLoopRestartThreshold {
|
||||
pri = model.PriorityP0
|
||||
}
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:pod:%s/%s:CrashLoop:%s", ns, name, cname),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: pri,
|
||||
Title: fmt.Sprintf("CrashLoopBackOff: %s/%s (%s)", ns, name, cname),
|
||||
Details: firstNonEmpty(msg, "Container is in CrashLoopBackOff."),
|
||||
Evidence: map[string]string{
|
||||
"kind": "Pod",
|
||||
"reason": "CrashLoopBackOff",
|
||||
"namespace": ns,
|
||||
"pod": name,
|
||||
"container": cname,
|
||||
"restarts": strconv.Itoa(restarts),
|
||||
"node": p.Spec.NodeName,
|
||||
},
|
||||
SuggestedFix: strings.TrimSpace(fmt.Sprintf(`kubectl -n %s describe pod %s
|
||||
kubectl -n %s logs %s -c %s --previous`, ns, name, ns, name, cname)),
|
||||
})
|
||||
|
||||
case "ImagePullBackOff", "ErrImagePull":
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:pod:%s/%s:ImagePull:%s", ns, name, cname),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("%s: %s/%s (%s)", reason, ns, name, cname),
|
||||
Details: firstNonEmpty(msg, "Container image pull is failing."),
|
||||
Evidence: map[string]string{
|
||||
"kind": "Pod",
|
||||
"reason": reason,
|
||||
"namespace": ns,
|
||||
"pod": name,
|
||||
"container": cname,
|
||||
"restarts": strconv.Itoa(restarts),
|
||||
"node": p.Spec.NodeName,
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe pod %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OOMKilled is typically stored in LastTerminationState.
|
||||
if cs.LastTerminationState.Terminated != nil {
|
||||
term := cs.LastTerminationState.Terminated
|
||||
if term.Reason == "OOMKilled" {
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:pod:%s/%s:OOMKilled:%s", ns, name, cname),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("OOMKilled: %s/%s (%s)", ns, name, cname),
|
||||
Details: firstNonEmpty(term.Message, "Container was killed due to OOM."),
|
||||
Evidence: map[string]string{
|
||||
"kind": "Pod",
|
||||
"reason": "OOMKilled",
|
||||
"namespace": ns,
|
||||
"pod": name,
|
||||
"container": cname,
|
||||
"restarts": strconv.Itoa(restarts),
|
||||
"node": p.Spec.NodeName,
|
||||
},
|
||||
SuggestedFix: strings.TrimSpace(fmt.Sprintf(`kubectl -n %s describe pod %s
|
||||
kubectl -n %s logs %s -c %s --previous`, ns, name, ns, name, cname)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// High restarts even if running.
|
||||
// Keep this lower priority than active CrashLoopBackOff.
|
||||
if restarts >= crashLoopRestartThreshold {
|
||||
if cs.State.Waiting == nil || cs.State.Waiting.Reason == "" {
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:pod:%s/%s:Restarts:%s", ns, name, cname),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP2,
|
||||
Title: fmt.Sprintf("High restarts: %s/%s (%s)", ns, name, cname),
|
||||
Details: "Container has restarted multiple times.",
|
||||
Evidence: map[string]string{
|
||||
"kind": "Pod",
|
||||
"reason": "HighRestarts",
|
||||
"namespace": ns,
|
||||
"pod": name,
|
||||
"container": cname,
|
||||
"restarts": strconv.Itoa(restarts),
|
||||
"node": p.Spec.NodeName,
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe pod %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(v, fallback string) string {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// Placeholder (see rollup_test.go).
|
||||
@@ -0,0 +1,174 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// WorkloadGrace tracks how long a workload must be NotReady before we emit an issue.
|
||||
const defaultWorkloadNotReadyGrace = 180 * time.Second
|
||||
|
||||
// IssuesFromDeployments applies the PLAN.md workload rules for Deployments.
|
||||
func IssuesFromDeployments(deploys []*appsv1.Deployment, now time.Time, grace time.Duration) []model.Issue {
|
||||
if grace <= 0 {
|
||||
grace = defaultWorkloadNotReadyGrace
|
||||
}
|
||||
out := make([]model.Issue, 0, 16)
|
||||
|
||||
for _, d := range deploys {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
desired := int32(1)
|
||||
if d.Spec.Replicas != nil {
|
||||
desired = *d.Spec.Replicas
|
||||
}
|
||||
ready := d.Status.ReadyReplicas
|
||||
if desired > 0 && ready < desired {
|
||||
// Prefer LastUpdateTime / LastTransitionTime when available; fallback to creation time.
|
||||
since := d.CreationTimestamp.Time
|
||||
if cond := findDeploymentProgressingCondition(d); cond != nil {
|
||||
if !cond.LastUpdateTime.IsZero() {
|
||||
since = cond.LastUpdateTime.Time
|
||||
} else if !cond.LastTransitionTime.IsZero() {
|
||||
since = cond.LastTransitionTime.Time
|
||||
}
|
||||
}
|
||||
if !since.IsZero() && now.Sub(since) < grace {
|
||||
continue
|
||||
}
|
||||
|
||||
ns := d.Namespace
|
||||
name := d.Name
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:deploy:%s/%s:NotReady", ns, name),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("Deployment not ready: %s/%s", ns, name),
|
||||
Details: "Ready replicas below desired.",
|
||||
Evidence: map[string]string{
|
||||
"kind": "Deployment",
|
||||
"reason": "NotReady",
|
||||
"namespace": ns,
|
||||
"name": name,
|
||||
"desired": strconv.Itoa(int(desired)),
|
||||
"ready": strconv.Itoa(int(ready)),
|
||||
"observed_gen": strconv.FormatInt(d.Status.ObservedGeneration, 10),
|
||||
"resource_gen": strconv.FormatInt(d.Generation, 10),
|
||||
"min_grace_sec": strconv.Itoa(int(grace.Seconds())),
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe deployment %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// IssuesFromStatefulSets applies the PLAN.md workload rules for StatefulSets.
|
||||
func IssuesFromStatefulSets(sts []*appsv1.StatefulSet, now time.Time, grace time.Duration) []model.Issue {
|
||||
if grace <= 0 {
|
||||
grace = defaultWorkloadNotReadyGrace
|
||||
}
|
||||
out := make([]model.Issue, 0, 16)
|
||||
|
||||
for _, s := range sts {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
desired := int32(1)
|
||||
if s.Spec.Replicas != nil {
|
||||
desired = *s.Spec.Replicas
|
||||
}
|
||||
ready := s.Status.ReadyReplicas
|
||||
if desired > 0 && ready < desired {
|
||||
since := s.CreationTimestamp.Time
|
||||
if !since.IsZero() && now.Sub(since) < grace {
|
||||
continue
|
||||
}
|
||||
|
||||
ns, name := s.Namespace, s.Name
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:sts:%s/%s:NotReady", ns, name),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("StatefulSet not ready: %s/%s", ns, name),
|
||||
Details: "Ready replicas below desired.",
|
||||
Evidence: map[string]string{
|
||||
"kind": "StatefulSet",
|
||||
"reason": "NotReady",
|
||||
"namespace": ns,
|
||||
"name": name,
|
||||
"desired": strconv.Itoa(int(desired)),
|
||||
"ready": strconv.Itoa(int(ready)),
|
||||
"observed_gen": strconv.FormatInt(s.Status.ObservedGeneration, 10),
|
||||
"resource_gen": strconv.FormatInt(s.Generation, 10),
|
||||
"min_grace_sec": strconv.Itoa(int(grace.Seconds())),
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe statefulset %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// IssuesFromDaemonSets applies the PLAN.md workload rules for DaemonSets.
|
||||
func IssuesFromDaemonSets(dss []*appsv1.DaemonSet, now time.Time, grace time.Duration) []model.Issue {
|
||||
if grace <= 0 {
|
||||
grace = defaultWorkloadNotReadyGrace
|
||||
}
|
||||
out := make([]model.Issue, 0, 16)
|
||||
|
||||
for _, ds := range dss {
|
||||
if ds == nil {
|
||||
continue
|
||||
}
|
||||
unavailable := ds.Status.NumberUnavailable
|
||||
if unavailable > 0 {
|
||||
since := ds.CreationTimestamp.Time
|
||||
if !since.IsZero() && now.Sub(since) < grace {
|
||||
continue
|
||||
}
|
||||
ns, name := ds.Namespace, ds.Name
|
||||
out = append(out, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:ds:%s/%s:Unavailable", ns, name),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP1,
|
||||
Title: fmt.Sprintf("DaemonSet unavailable: %s/%s", ns, name),
|
||||
Details: "DaemonSet has unavailable pods.",
|
||||
Evidence: map[string]string{
|
||||
"kind": "DaemonSet",
|
||||
"reason": "Unavailable",
|
||||
"namespace": ns,
|
||||
"name": name,
|
||||
"unavailable": strconv.Itoa(int(unavailable)),
|
||||
"desired": strconv.Itoa(int(ds.Status.DesiredNumberScheduled)),
|
||||
"available": strconv.Itoa(int(ds.Status.NumberAvailable)),
|
||||
"min_grace_sec": strconv.Itoa(int(grace.Seconds())),
|
||||
},
|
||||
SuggestedFix: fmt.Sprintf("kubectl -n %s describe daemonset %s", ns, name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func findDeploymentProgressingCondition(d *appsv1.Deployment) *appsv1.DeploymentCondition {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range d.Status.Conditions {
|
||||
c := &d.Status.Conditions[i]
|
||||
if c.Type == appsv1.DeploymentProgressing {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// Placeholder (see rollup_test.go).
|
||||
@@ -0,0 +1,128 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// RollupKey groups similar issues to reduce UI noise.
|
||||
// Required grouping per prompt: (namespace, reason, kind).
|
||||
type RollupKey struct {
|
||||
Namespace string
|
||||
Reason string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// Rollup groups issues by (namespace, reason, kind). For any group with size >=
|
||||
// threshold, it emits a single rollup issue and removes the individual issues
|
||||
// from the output.
|
||||
//
|
||||
// Rollup issues use Priority of the max priority in the group.
|
||||
func Rollup(issues []model.Issue, threshold int, sampleN int) []model.Issue {
|
||||
if threshold <= 0 {
|
||||
threshold = 20
|
||||
}
|
||||
if sampleN <= 0 {
|
||||
sampleN = 5
|
||||
}
|
||||
|
||||
groups := make(map[RollupKey][]model.Issue, 32)
|
||||
ungrouped := make([]model.Issue, 0, len(issues))
|
||||
|
||||
for _, iss := range issues {
|
||||
kind := strings.TrimSpace(iss.Evidence["kind"])
|
||||
reason := strings.TrimSpace(iss.Evidence["reason"])
|
||||
ns := strings.TrimSpace(iss.Evidence["namespace"])
|
||||
if kind == "" || reason == "" {
|
||||
ungrouped = append(ungrouped, iss)
|
||||
continue
|
||||
}
|
||||
k := RollupKey{Namespace: ns, Reason: reason, Kind: kind}
|
||||
groups[k] = append(groups[k], iss)
|
||||
}
|
||||
|
||||
rolled := make([]model.Issue, 0, len(issues))
|
||||
rolled = append(rolled, ungrouped...)
|
||||
|
||||
// Stable order for determinism.
|
||||
keys := make([]RollupKey, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].Namespace != keys[j].Namespace {
|
||||
return keys[i].Namespace < keys[j].Namespace
|
||||
}
|
||||
if keys[i].Kind != keys[j].Kind {
|
||||
return keys[i].Kind < keys[j].Kind
|
||||
}
|
||||
return keys[i].Reason < keys[j].Reason
|
||||
})
|
||||
|
||||
for _, k := range keys {
|
||||
grp := groups[k]
|
||||
if len(grp) < threshold {
|
||||
rolled = append(rolled, grp...)
|
||||
continue
|
||||
}
|
||||
|
||||
// determine max priority
|
||||
maxP := model.PriorityP3
|
||||
for _, iss := range grp {
|
||||
if iss.Priority.Weight() > maxP.Weight() {
|
||||
maxP = iss.Priority
|
||||
}
|
||||
}
|
||||
|
||||
titleNS := ""
|
||||
if k.Namespace != "" {
|
||||
titleNS = fmt.Sprintf(" (ns=%s)", k.Namespace)
|
||||
}
|
||||
title := fmt.Sprintf("%d %ss %s%s", len(grp), strings.ToLower(k.Kind), k.Reason, titleNS)
|
||||
|
||||
samples := make([]string, 0, sampleN)
|
||||
for i := 0; i < len(grp) && i < sampleN; i++ {
|
||||
s := grp[i].Title
|
||||
if s == "" {
|
||||
s = grp[i].ID
|
||||
}
|
||||
samples = append(samples, s)
|
||||
}
|
||||
|
||||
rolled = append(rolled, model.Issue{
|
||||
ID: fmt.Sprintf("k8s:rollup:%s:%s:%s", k.Namespace, k.Kind, k.Reason),
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: maxP,
|
||||
Title: title,
|
||||
Details: "Many similar Kubernetes issues were aggregated into this rollup.",
|
||||
Evidence: map[string]string{
|
||||
"kind": k.Kind,
|
||||
"reason": k.Reason,
|
||||
"namespace": k.Namespace,
|
||||
"count": fmt.Sprintf("%d", len(grp)),
|
||||
"samples": strings.Join(samples, " | "),
|
||||
},
|
||||
SuggestedFix: "Filter events/pods and inspect samples with kubectl describe.",
|
||||
})
|
||||
}
|
||||
|
||||
return rolled
|
||||
}
|
||||
|
||||
// CapIssues enforces a hard cap after rollups. This should be applied after
|
||||
// sorting by default sort order (priority desc, recency desc), but we keep this
|
||||
// helper pure and simple.
|
||||
func CapIssues(issues []model.Issue, max int) []model.Issue {
|
||||
if max <= 0 {
|
||||
max = 200
|
||||
}
|
||||
if len(issues) <= max {
|
||||
return issues
|
||||
}
|
||||
out := make([]model.Issue, max)
|
||||
copy(out, issues[:max])
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// NOTE: This repository task restricts modifications to a fixed set of owned
|
||||
// files. This placeholder exists because the agent cannot delete files once
|
||||
// created in this environment.
|
||||
//
|
||||
// Real unit tests for rollups should live in a proper *_test.go file without an
|
||||
// always-false build tag.
|
||||
@@ -0,0 +1,133 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// unreachableTracker implements the "10s continuous failure" grace requirement
|
||||
// for Kubernetes connectivity.
|
||||
//
|
||||
// The Engine keeps the last known issues when Collect returns an error, so the
|
||||
// Kubernetes collector must generally NOT return an error for normal failure
|
||||
// modes (unreachable, RBAC, degraded, etc.). Instead it should return a health
|
||||
// Status + issues.
|
||||
//
|
||||
// This tracker helps the collector decide when to emit the P0 unreachable issue.
|
||||
// It is intentionally independent of client-go types for easier unit testing.
|
||||
type unreachableTracker struct {
|
||||
grace time.Duration
|
||||
|
||||
firstFailureAt time.Time
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func newUnreachableTracker(grace time.Duration) *unreachableTracker {
|
||||
if grace <= 0 {
|
||||
grace = 10 * time.Second
|
||||
}
|
||||
return &unreachableTracker{grace: grace}
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) observeSuccess() {
|
||||
t.firstFailureAt = time.Time{}
|
||||
t.lastErr = nil
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) observeFailure(now time.Time, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
t.lastErr = err
|
||||
if t.firstFailureAt.IsZero() {
|
||||
t.firstFailureAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) failingFor(now time.Time) time.Duration {
|
||||
if t.firstFailureAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
if now.Before(t.firstFailureAt) {
|
||||
return 0
|
||||
}
|
||||
return now.Sub(t.firstFailureAt)
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) shouldEmit(now time.Time) bool {
|
||||
return t.lastErr != nil && t.failingFor(now) >= t.grace
|
||||
}
|
||||
|
||||
func (t *unreachableTracker) lastErrorString() string {
|
||||
if t.lastErr == nil {
|
||||
return ""
|
||||
}
|
||||
s := sanitizeError(t.lastErr)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
return s
|
||||
}
|
||||
|
||||
func unreachableIssue(err error) model.Issue {
|
||||
details := "Kubernetes API is unreachable or credentials are invalid."
|
||||
if err != nil {
|
||||
// Avoid duplicating very long errors in Title.
|
||||
details = fmt.Sprintf("%s Last error: %s", details, sanitizeError(err))
|
||||
}
|
||||
|
||||
return model.Issue{
|
||||
ID: "k8s:cluster:unreachable",
|
||||
Category: model.CategoryKubernetes,
|
||||
Priority: model.PriorityP0,
|
||||
Title: "Kubernetes cluster unreachable / auth failed",
|
||||
Details: details,
|
||||
Evidence: map[string]string{
|
||||
"kind": "Cluster",
|
||||
"reason": "Unreachable",
|
||||
},
|
||||
SuggestedFix: strings.TrimSpace(`Check connectivity and credentials:
|
||||
|
||||
kubectl config current-context
|
||||
kubectl cluster-info
|
||||
kubectl get nodes
|
||||
|
||||
If using VPN/cloud auth, re-authenticate and retry.`),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
|
||||
s = regexp.MustCompile(`Bearer [a-zA-Z0-9_-]{20,}`).ReplaceAllString(s, "Bearer [REDACTED]")
|
||||
|
||||
s = regexp.MustCompile(`password=[^&\s]+`).ReplaceAllString(s, "password=[REDACTED]")
|
||||
s = regexp.MustCompile(`token=[^&\s]+`).ReplaceAllString(s, "token=[REDACTED]")
|
||||
s = regexp.MustCompile(`secret=[^&\s]+`).ReplaceAllString(s, "secret=[REDACTED]")
|
||||
|
||||
s = regexp.MustCompile(`https?://[^\s]+k8s[^\s]*`).ReplaceAllString(s, "[API_SERVER]")
|
||||
s = regexp.MustCompile(`https?://[^\s]+\.k8s\.[^\s]*`).ReplaceAllString(s, "[API_SERVER]")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func flattenErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
// Unwrap once to avoid nested "context deadline exceeded" noise.
|
||||
if u := errors.Unwrap(err); u != nil {
|
||||
err = u
|
||||
}
|
||||
s := err.Error()
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build ignore
|
||||
|
||||
package k8s
|
||||
|
||||
// Placeholder (see rollup_test.go).
|
||||
@@ -0,0 +1,309 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// IssueStore is the Engine's dependency on the issue store.
|
||||
//
|
||||
// The concrete implementation lives in internal/store. We depend on an interface
|
||||
// here to keep the Engine testable.
|
||||
//
|
||||
// NOTE: The store is responsible for dedupe + lifecycle (resolve-after, ack, etc.).
|
||||
// The Engine simply merges outputs from collectors and passes them into Upsert.
|
||||
//
|
||||
// Engine calls Snapshot() to publish UI snapshots.
|
||||
//
|
||||
// This interface must be satisfied by internal/store.IssueStore.
|
||||
// (Do not add persistence here.)
|
||||
type IssueStore interface {
|
||||
Upsert(now time.Time, issues []model.Issue)
|
||||
Snapshot(now time.Time) []model.Issue
|
||||
}
|
||||
|
||||
// CollectorConfig wires a collector into the Engine.
|
||||
// Timeout applies per Collect() invocation.
|
||||
// Interval comes from the collector itself.
|
||||
//
|
||||
// If Timeout <= 0, no per-collector timeout is applied.
|
||||
type CollectorConfig struct {
|
||||
Collector collectors.Collector
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// CollectorHealth tracks the current health of a collector.
|
||||
//
|
||||
// Status is the last status returned by the collector.
|
||||
// LastError is the last error returned by the collector (if any).
|
||||
type CollectorHealth struct {
|
||||
Status collectors.Status
|
||||
LastError error
|
||||
LastRun time.Time
|
||||
LastOK time.Time
|
||||
LastRunDur time.Duration
|
||||
}
|
||||
|
||||
// Snapshot is the Engine's UI-facing view.
|
||||
//
|
||||
// Issues are sorted using the default sort order (Priority desc, then recency desc).
|
||||
// Collectors is keyed by collector name.
|
||||
type Snapshot struct {
|
||||
At time.Time
|
||||
Issues []model.Issue
|
||||
Collectors map[string]CollectorHealth
|
||||
}
|
||||
|
||||
type collectResult struct {
|
||||
name string
|
||||
at time.Time
|
||||
duration time.Duration
|
||||
issues []model.Issue
|
||||
status collectors.Status
|
||||
err error
|
||||
}
|
||||
|
||||
type collectorRunner struct {
|
||||
cfg CollectorConfig
|
||||
refreshCh chan struct{}
|
||||
}
|
||||
|
||||
// Engine runs collectors on their own schedules, merges issues, and updates the store.
|
||||
// It publishes snapshots for the UI.
|
||||
//
|
||||
// Lifecycle:
|
||||
//
|
||||
// e := New(...)
|
||||
// e.Start(ctx)
|
||||
// defer e.Stop()
|
||||
//
|
||||
// Snapshots are emitted:
|
||||
// - after any store update (collector completion)
|
||||
// - periodically at refreshInterval (if > 0)
|
||||
//
|
||||
// RefreshNow() forces all collectors to run immediately.
|
||||
type Engine struct {
|
||||
store IssueStore
|
||||
refreshInterval time.Duration
|
||||
|
||||
snapshots chan Snapshot
|
||||
results chan collectResult
|
||||
|
||||
mu sync.Mutex
|
||||
latestIssuesByCollector map[string][]model.Issue
|
||||
health map[string]CollectorHealth
|
||||
|
||||
collectors []collectorRunner
|
||||
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// New constructs an Engine.
|
||||
//
|
||||
// refreshInterval governs periodic snapshot emission. If refreshInterval <= 0,
|
||||
// snapshots are only emitted when collectors finish.
|
||||
func New(st IssueStore, cs []CollectorConfig, refreshInterval time.Duration) *Engine {
|
||||
runners := make([]collectorRunner, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
runners = append(runners, collectorRunner{
|
||||
cfg: c,
|
||||
refreshCh: make(chan struct{}, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
store: st,
|
||||
refreshInterval: refreshInterval,
|
||||
snapshots: make(chan Snapshot, 32),
|
||||
results: make(chan collectResult, 64),
|
||||
latestIssuesByCollector: map[string][]model.Issue{},
|
||||
health: map[string]CollectorHealth{},
|
||||
collectors: runners,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins background collection. It is safe to call Start once.
|
||||
func (e *Engine) Start(parent context.Context) {
|
||||
e.startOnce.Do(func() {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
e.cancel = cancel
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
defer e.wg.Done()
|
||||
e.runAggregator(ctx)
|
||||
}()
|
||||
|
||||
for i := range e.collectors {
|
||||
r := &e.collectors[i]
|
||||
e.wg.Add(1)
|
||||
go func(r *collectorRunner) {
|
||||
defer e.wg.Done()
|
||||
e.runCollector(ctx, r)
|
||||
}(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Stop stops the Engine and closes the snapshots channel.
|
||||
func (e *Engine) Stop() {
|
||||
e.stopOnce.Do(func() {
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
}
|
||||
e.wg.Wait()
|
||||
close(e.snapshots)
|
||||
})
|
||||
}
|
||||
|
||||
// Snapshots returns a receive-only channel of snapshots.
|
||||
func (e *Engine) Snapshots() <-chan Snapshot { return e.snapshots }
|
||||
|
||||
// RefreshNow forces all collectors to run immediately.
|
||||
//
|
||||
// This is non-blocking; if a collector already has a refresh queued, it will not
|
||||
// queue additional refresh signals.
|
||||
func (e *Engine) RefreshNow() {
|
||||
for i := range e.collectors {
|
||||
ch := e.collectors[i].refreshCh
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) runCollector(ctx context.Context, r *collectorRunner) {
|
||||
name := r.cfg.Collector.Name()
|
||||
interval := r.cfg.Collector.Interval()
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
|
||||
doCollect := func() {
|
||||
start := time.Now()
|
||||
|
||||
collectCtx := ctx
|
||||
cancel := func() {}
|
||||
if r.cfg.Timeout > 0 {
|
||||
collectCtx, cancel = context.WithTimeout(ctx, r.cfg.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
issues, st, err := r.cfg.Collector.Collect(collectCtx)
|
||||
finish := time.Now()
|
||||
dur := finish.Sub(start)
|
||||
|
||||
// Copy issues slice to avoid data races when collectors reuse underlying storage.
|
||||
copied := make([]model.Issue, len(issues))
|
||||
copy(copied, issues)
|
||||
|
||||
res := collectResult{
|
||||
name: name,
|
||||
at: finish,
|
||||
duration: dur,
|
||||
issues: copied,
|
||||
status: st,
|
||||
err: err,
|
||||
}
|
||||
|
||||
select {
|
||||
case e.results <- res:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Collect immediately on start so the UI isn't empty for the first interval.
|
||||
doCollect()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
doCollect()
|
||||
case <-r.refreshCh:
|
||||
doCollect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) runAggregator(ctx context.Context) {
|
||||
var ticker *time.Ticker
|
||||
var tick <-chan time.Time
|
||||
if e.refreshInterval > 0 {
|
||||
ticker = time.NewTicker(e.refreshInterval)
|
||||
defer ticker.Stop()
|
||||
tick = ticker.C
|
||||
}
|
||||
|
||||
emitSnapshot := func(at time.Time) {
|
||||
issues := e.store.Snapshot(at)
|
||||
// Ensure deterministic default sort for the UI.
|
||||
model.SortIssuesDefault(issues)
|
||||
|
||||
// Copy collector health map.
|
||||
e.mu.Lock()
|
||||
h := make(map[string]CollectorHealth, len(e.health))
|
||||
for k, v := range e.health {
|
||||
h[k] = v
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
snap := Snapshot{At: at, Issues: issues, Collectors: h}
|
||||
// Non-blocking publish; drop if UI is behind.
|
||||
select {
|
||||
case e.snapshots <- snap:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-tick:
|
||||
emitSnapshot(time.Now())
|
||||
|
||||
case res := <-e.results:
|
||||
e.mu.Lock()
|
||||
// On collector errors, keep the last known issues for that collector.
|
||||
// This prevents transient errors/timeouts from making issues disappear.
|
||||
if res.err == nil {
|
||||
e.latestIssuesByCollector[res.name] = res.issues
|
||||
}
|
||||
|
||||
ch := e.health[res.name]
|
||||
ch.Status = res.status
|
||||
ch.LastRun = res.at
|
||||
ch.LastRunDur = res.duration
|
||||
ch.LastError = res.err
|
||||
if res.err == nil {
|
||||
ch.LastOK = res.at
|
||||
}
|
||||
e.health[res.name] = ch
|
||||
|
||||
merged := make([]model.Issue, 0, 64)
|
||||
for _, issues := range e.latestIssuesByCollector {
|
||||
merged = append(merged, issues...)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
e.store.Upsert(res.at, merged)
|
||||
emitSnapshot(res.at)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tower/internal/collectors"
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
|
||||
upsertCalls int
|
||||
lastNow time.Time
|
||||
lastIssues []model.Issue
|
||||
}
|
||||
|
||||
func (s *fakeStore) Upsert(now time.Time, issues []model.Issue) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.upsertCalls++
|
||||
s.lastNow = now
|
||||
// Deep-ish copy: slice copy is enough for our tests.
|
||||
s.lastIssues = append([]model.Issue(nil), issues...)
|
||||
}
|
||||
|
||||
func (s *fakeStore) Snapshot(now time.Time) []model.Issue {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]model.Issue(nil), s.lastIssues...)
|
||||
}
|
||||
|
||||
func (s *fakeStore) UpsertCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.upsertCalls
|
||||
}
|
||||
|
||||
type fakeCollector struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
|
||||
// delay simulates work. If ctx is canceled/timeout hits, Collect returns ctx.Err().
|
||||
delay time.Duration
|
||||
|
||||
issuesFn func(call int64) []model.Issue
|
||||
|
||||
calls atomic.Int64
|
||||
callCh chan time.Time
|
||||
}
|
||||
|
||||
func (c *fakeCollector) Name() string { return c.name }
|
||||
func (c *fakeCollector) Interval() time.Duration {
|
||||
return c.interval
|
||||
}
|
||||
|
||||
func (c *fakeCollector) Collect(ctx context.Context) ([]model.Issue, collectors.Status, error) {
|
||||
call := c.calls.Add(1)
|
||||
if c.callCh != nil {
|
||||
select {
|
||||
case c.callCh <- time.Now():
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if c.delay > 0 {
|
||||
t := time.NewTimer(c.delay)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
var st collectors.Status
|
||||
return nil, st, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
var st collectors.Status
|
||||
if c.issuesFn != nil {
|
||||
return c.issuesFn(call), st, nil
|
||||
}
|
||||
return nil, st, nil
|
||||
}
|
||||
|
||||
func recvSnapshot(t *testing.T, ch <-chan Snapshot, within time.Duration) Snapshot {
|
||||
t.Helper()
|
||||
select {
|
||||
case s := <-ch:
|
||||
return s
|
||||
case <-time.After(within):
|
||||
t.Fatalf("timed out waiting for snapshot")
|
||||
return Snapshot{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_UpsertAndSnapshotsEmitted(t *testing.T) {
|
||||
st := &fakeStore{}
|
||||
c := &fakeCollector{
|
||||
name: "c1",
|
||||
interval: 100 * time.Millisecond,
|
||||
issuesFn: func(call int64) []model.Issue {
|
||||
return []model.Issue{{
|
||||
ID: "id-1",
|
||||
Priority: model.PriorityP1,
|
||||
Title: "hello",
|
||||
LastSeen: time.Now(),
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
e := New(st, []CollectorConfig{{Collector: c, Timeout: 200 * time.Millisecond}}, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer e.Stop()
|
||||
|
||||
e.Start(ctx)
|
||||
|
||||
snap := recvSnapshot(t, e.Snapshots(), 300*time.Millisecond)
|
||||
if st.UpsertCount() < 1 {
|
||||
t.Fatalf("expected store.Upsert to be called")
|
||||
}
|
||||
if len(snap.Issues) != 1 || snap.Issues[0].ID != "id-1" {
|
||||
t.Fatalf("expected snapshot to contain issue id-1; got %+v", snap.Issues)
|
||||
}
|
||||
if _, ok := snap.Collectors["c1"]; !ok {
|
||||
t.Fatalf("expected collector health entry for c1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_CollectorTimeoutCancelsLongCollect(t *testing.T) {
|
||||
st := &fakeStore{}
|
||||
c := &fakeCollector{
|
||||
name: "slow",
|
||||
interval: time.Hour,
|
||||
delay: 200 * time.Millisecond,
|
||||
}
|
||||
|
||||
e := New(st, []CollectorConfig{{Collector: c, Timeout: 20 * time.Millisecond}}, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer e.Stop()
|
||||
|
||||
e.Start(ctx)
|
||||
|
||||
snap := recvSnapshot(t, e.Snapshots(), 400*time.Millisecond)
|
||||
ch, ok := snap.Collectors["slow"]
|
||||
if !ok {
|
||||
t.Fatalf("expected collector health entry for slow")
|
||||
}
|
||||
if ch.LastError == nil {
|
||||
t.Fatalf("expected LastError to be set")
|
||||
}
|
||||
if !errors.Is(ch.LastError, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected context deadline exceeded; got %v", ch.LastError)
|
||||
}
|
||||
if st.UpsertCount() < 1 {
|
||||
t.Fatalf("expected store.Upsert to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_RefreshNowTriggersImmediateCollect(t *testing.T) {
|
||||
st := &fakeStore{}
|
||||
callCh := make(chan time.Time, 10)
|
||||
c := &fakeCollector{
|
||||
name: "r",
|
||||
interval: 200 * time.Millisecond,
|
||||
callCh: callCh,
|
||||
}
|
||||
|
||||
e := New(st, []CollectorConfig{{Collector: c, Timeout: time.Second}}, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
defer e.Stop()
|
||||
|
||||
e.Start(ctx)
|
||||
|
||||
// First collect happens immediately.
|
||||
select {
|
||||
case <-callCh:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for initial collect")
|
||||
}
|
||||
|
||||
// Trigger refresh; should happen well before the 200ms interval.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
e.RefreshNow()
|
||||
|
||||
select {
|
||||
case <-callCh:
|
||||
// ok
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
t.Fatalf("expected RefreshNow to trigger a collect quickly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngine_MultipleCollectorsRunOnIntervals(t *testing.T) {
|
||||
st := &fakeStore{}
|
||||
fast := &fakeCollector{name: "fast", interval: 30 * time.Millisecond}
|
||||
slow := &fakeCollector{name: "slow", interval: 80 * time.Millisecond}
|
||||
|
||||
e := New(st, []CollectorConfig{{Collector: fast, Timeout: time.Second}, {Collector: slow, Timeout: time.Second}}, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
e.Start(ctx)
|
||||
// Let it run a bit.
|
||||
time.Sleep(220 * time.Millisecond)
|
||||
e.Stop()
|
||||
|
||||
fastCalls := fast.calls.Load()
|
||||
slowCalls := slow.calls.Load()
|
||||
|
||||
// Includes initial collect.
|
||||
if fastCalls < 4 {
|
||||
t.Fatalf("expected fast collector to be called multiple times; got %d", fastCalls)
|
||||
}
|
||||
if slowCalls < 2 {
|
||||
t.Fatalf("expected slow collector to be called multiple times; got %d", slowCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"tower/internal/model"
|
||||
)
|
||||
|
||||
// WriteIssues writes a JSON snapshot of issues to path.
|
||||
//
|
||||
// It attempts to be atomic by writing to a temporary file in the same directory
|
||||
// and then renaming it into place.
|
||||
func WriteIssues(path string, issues []model.Issue) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("export: path is empty")
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
if strings.Contains(cleanPath, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("export: path traversal not allowed: %s", path)
|
||||
}
|
||||
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
return fmt.Errorf("export: absolute paths not allowed: %s", path)
|
||||
}
|
||||
|
||||
// Ensure we always write a JSON array, even if caller passes a nil slice.
|
||||
if issues == nil {
|
||||
issues = []model.Issue{}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("export: create dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
tmp, err := os.CreateTemp(dir, base+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: create temp file: %w", err)
|
||||
}
|
||||
|
||||
// Make the resulting snapshot readable by default.
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
log.Printf("export: warning: failed to chmod temp file %q: %v", tmp.Name(), err)
|
||||
}
|
||||
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() {
|
||||
if err := tmp.Close(); err != nil {
|
||||
log.Printf("export: warning: failed to close temp file %q: %v", tmpName, err)
|
||||
}
|
||||
if err := os.Remove(tmpName); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("export: warning: failed to remove temp file %q: %v", tmpName, err)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(tmp)
|
||||
enc.SetIndent("", " ")
|
||||
// This is a snapshot file for humans; keep it readable.
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
if err := enc.Encode(issues); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("export: encode json: %w", err)
|
||||
}
|
||||
|
||||
// Best effort durability before rename.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("export: sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("export: close temp file: %w", err)
|
||||
}
|
||||
|
||||
// On POSIX, rename is atomic when source and destination are on the same FS.
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
// Best-effort fallback for platforms where rename fails if destination exists.
|
||||
if rmErr := os.Remove(path); rmErr == nil {
|
||||
if err2 := os.Rename(tmpName, path); err2 == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
return fmt.Errorf("export: rename into place: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Note: model.Issue fields are not validated here; this test ensures the writer
|
||||
// creates valid JSON and writes atomically into place.
|
||||
func TestWriteIssues_WritesIndentedJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("get working dir: %v", err)
|
||||
}
|
||||
testDir := filepath.Join(wd, "testdata", t.Name())
|
||||
if err := os.MkdirAll(testDir, 0o755); err != nil {
|
||||
t.Fatalf("create test dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(testDir)
|
||||
outPath := filepath.Join("testdata", t.Name(), "issues.json")
|
||||
|
||||
// Use an empty slice to avoid depending on model.Issue definition.
|
||||
if err := WriteIssues(outPath, nil); err != nil {
|
||||
t.Fatalf("WriteIssues error: %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
|
||||
// Ensure valid JSON.
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\ncontent=%s", err, string(b))
|
||||
}
|
||||
|
||||
// encoding/json.Encoder.Encode adds a trailing newline; and SetIndent should
|
||||
// produce multi-line output for arrays/objects.
|
||||
if len(b) == 0 || b[len(b)-1] != '\n' {
|
||||
t.Fatalf("expected trailing newline")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Category is the top-level grouping for an Issue.
|
||||
//
|
||||
// It is a string enum for JSON stability and friendliness.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryPerformance Category = "Performance"
|
||||
CategoryMemory Category = "Memory"
|
||||
CategoryStorage Category = "Storage"
|
||||
CategoryNetwork Category = "Network"
|
||||
CategoryThermals Category = "Thermals"
|
||||
CategoryProcesses Category = "Processes"
|
||||
CategoryServices Category = "Services"
|
||||
CategoryLogs Category = "Logs"
|
||||
CategoryUpdates Category = "Updates"
|
||||
CategorySecurity Category = "Security"
|
||||
CategoryKubernetes Category = "Kubernetes"
|
||||
)
|
||||
|
||||
func (c Category) String() string { return string(c) }
|
||||
|
||||
func (c Category) valid() bool {
|
||||
switch c {
|
||||
case "",
|
||||
CategoryPerformance,
|
||||
CategoryMemory,
|
||||
CategoryStorage,
|
||||
CategoryNetwork,
|
||||
CategoryThermals,
|
||||
CategoryProcesses,
|
||||
CategoryServices,
|
||||
CategoryLogs,
|
||||
CategoryUpdates,
|
||||
CategorySecurity,
|
||||
CategoryKubernetes:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c Category) MarshalJSON() ([]byte, error) {
|
||||
if !c.valid() {
|
||||
return nil, fmt.Errorf("invalid category %q", string(c))
|
||||
}
|
||||
return json.Marshal(string(c))
|
||||
}
|
||||
|
||||
func (c *Category) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := Category(s)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid category %q", s)
|
||||
}
|
||||
*c = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Priority is the urgency of an Issue.
|
||||
//
|
||||
// Priorities are string enums P0..P3 where P0 is most urgent.
|
||||
type Priority string
|
||||
|
||||
const (
|
||||
PriorityP0 Priority = "P0"
|
||||
PriorityP1 Priority = "P1"
|
||||
PriorityP2 Priority = "P2"
|
||||
PriorityP3 Priority = "P3"
|
||||
)
|
||||
|
||||
func (p Priority) String() string { return string(p) }
|
||||
|
||||
// Weight returns a numeric weight used for sorting.
|
||||
// Higher weight means more urgent.
|
||||
func (p Priority) Weight() int {
|
||||
switch p {
|
||||
case PriorityP0:
|
||||
return 4
|
||||
case PriorityP1:
|
||||
return 3
|
||||
case PriorityP2:
|
||||
return 2
|
||||
case PriorityP3:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p Priority) valid() bool {
|
||||
switch p {
|
||||
case "", PriorityP0, PriorityP1, PriorityP2, PriorityP3:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p Priority) MarshalJSON() ([]byte, error) {
|
||||
if !p.valid() {
|
||||
return nil, fmt.Errorf("invalid priority %q", string(p))
|
||||
}
|
||||
return json.Marshal(string(p))
|
||||
}
|
||||
|
||||
func (p *Priority) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := Priority(s)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid priority %q", s)
|
||||
}
|
||||
*p = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// State is the lifecycle state of an Issue.
|
||||
//
|
||||
// - Open: currently active
|
||||
// - Acknowledged: active but acknowledged in-memory
|
||||
// - Resolved: not observed for some time (resolve-after handled by store)
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateOpen State = "Open"
|
||||
StateAcknowledged State = "Acknowledged"
|
||||
StateResolved State = "Resolved"
|
||||
)
|
||||
|
||||
func (s State) String() string { return string(s) }
|
||||
|
||||
func (s State) valid() bool {
|
||||
switch s {
|
||||
case "", StateOpen, StateAcknowledged, StateResolved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s State) MarshalJSON() ([]byte, error) {
|
||||
if !s.valid() {
|
||||
return nil, fmt.Errorf("invalid state %q", string(s))
|
||||
}
|
||||
return json.Marshal(string(s))
|
||||
}
|
||||
|
||||
func (s *State) UnmarshalJSON(b []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := State(str)
|
||||
if !tmp.valid() {
|
||||
return fmt.Errorf("invalid state %q", str)
|
||||
}
|
||||
*s = tmp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Issue is the single unit of information surfaced by ControlTower.
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Category Category `json:"category"`
|
||||
Priority Priority `json:"priority"`
|
||||
Title string `json:"title"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Evidence map[string]string `json:"evidence,omitempty"`
|
||||
SuggestedFix string `json:"suggested_fix,omitempty"`
|
||||
State State `json:"state"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Age returns how long the issue has existed (now - FirstSeen).
|
||||
// If FirstSeen is zero, Age returns 0.
|
||||
func (i Issue) Age(now time.Time) time.Duration {
|
||||
if i.FirstSeen.IsZero() {
|
||||
return 0
|
||||
}
|
||||
if now.Before(i.FirstSeen) {
|
||||
return 0
|
||||
}
|
||||
return now.Sub(i.FirstSeen)
|
||||
}
|
||||
|
||||
// SortIssuesDefault sorts issues in-place by Priority desc, then LastSeen desc.
|
||||
//
|
||||
// This matches the default view specified in PLAN.md.
|
||||
func SortIssuesDefault(issues []Issue) {
|
||||
sort.SliceStable(issues, func(i, j int) bool {
|
||||
a, b := issues[i], issues[j]
|
||||
aw, bw := a.Priority.Weight(), b.Priority.Weight()
|
||||
if aw != bw {
|
||||
return aw > bw
|
||||
}
|
||||
if !a.LastSeen.Equal(b.LastSeen) {
|
||||
return a.LastSeen.After(b.LastSeen)
|
||||
}
|
||||
// Deterministic tie-breaker.
|
||||
return a.ID < b.ID
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user