mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-31 07:01:17 +01:00
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { useState } from "react"
|
|
import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
import type { ConfigFieldDef, SourceDefinition } from "@/lib/api"
|
|
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
|
|
interface SourceConfigPanelProps {
|
|
source: SourceDefinition
|
|
onUpdate: () => void
|
|
}
|
|
|
|
export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) {
|
|
const queryClient = useQueryClient()
|
|
const [dirty, setDirty] = useState<Record<string, unknown>>({})
|
|
|
|
const { data: serverConfig, isLoading } = useQuery({
|
|
queryKey: ["sourceConfig", source.id],
|
|
queryFn: () => fetchSourceConfig(source.id),
|
|
})
|
|
|
|
const enabled = serverConfig?.enabled ?? false
|
|
const serverValues = buildInitialValues(source.fields, serverConfig?.config)
|
|
const formValues = { ...serverValues, ...dirty }
|
|
|
|
function isCredentialField(field: ConfigFieldDef): boolean {
|
|
return !!(field.secret && field.required)
|
|
}
|
|
|
|
function getUserConfig(): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {}
|
|
for (const [name, value] of Object.entries(formValues)) {
|
|
const field = source.fields[name]
|
|
if (field && !isCredentialField(field)) {
|
|
result[name] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function getCredentialFields(): Record<string, unknown> {
|
|
const creds: Record<string, unknown> = {}
|
|
for (const [name, value] of Object.entries(formValues)) {
|
|
const field = source.fields[name]
|
|
if (field && isCredentialField(field)) {
|
|
creds[name] = value
|
|
}
|
|
}
|
|
return creds
|
|
}
|
|
|
|
function invalidate() {
|
|
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
|
|
queryClient.invalidateQueries({ queryKey: ["configs"] })
|
|
onUpdate()
|
|
}
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const promises: Promise<void>[] = [
|
|
replaceSource(source.id, { enabled, config: getUserConfig() }),
|
|
]
|
|
|
|
const credentialFields = getCredentialFields()
|
|
const hasCredentials = Object.values(credentialFields).some(
|
|
(v) => typeof v === "string" && v.length > 0,
|
|
)
|
|
if (hasCredentials) {
|
|
promises.push(
|
|
updateProviderConfig(source.id, { credentials: credentialFields }),
|
|
)
|
|
}
|
|
|
|
await Promise.all(promises)
|
|
},
|
|
onSuccess() {
|
|
setDirty({})
|
|
invalidate()
|
|
toast.success("Configuration saved")
|
|
},
|
|
onError(err) {
|
|
toast.error(err.message)
|
|
},
|
|
})
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: (checked: boolean) =>
|
|
replaceSource(source.id, { enabled: checked, config: getUserConfig() }),
|
|
onSuccess(_data, checked) {
|
|
invalidate()
|
|
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
|
|
},
|
|
onError(err) {
|
|
toast.error(err.message)
|
|
},
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }),
|
|
onSuccess() {
|
|
setDirty({})
|
|
invalidate()
|
|
toast.success("Configuration deleted")
|
|
},
|
|
onError(err) {
|
|
toast.error(err.message)
|
|
},
|
|
})
|
|
|
|
function handleFieldChange(fieldName: string, value: unknown) {
|
|
setDirty((prev) => ({ ...prev, [fieldName]: value }))
|
|
}
|
|
|
|
const fieldEntries = Object.entries(source.fields)
|
|
const hasFields = fieldEntries.length > 0
|
|
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
|
|
|
|
const requiredFields = fieldEntries.filter(([, f]) => f.required)
|
|
const optionalFields = fieldEntries.filter(([, f]) => !f.required)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-xl space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
|
|
{source.alwaysEnabled ? (
|
|
<Badge variant="secondary">Always on</Badge>
|
|
) : enabled ? (
|
|
<Badge className="bg-primary/10 text-primary">Enabled</Badge>
|
|
) : (
|
|
<Badge variant="outline">Disabled</Badge>
|
|
)}
|
|
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">{source.description}</p>
|
|
</div>
|
|
{!source.alwaysEnabled && (
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
|
disabled={busy}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Config form */}
|
|
{hasFields && !source.alwaysEnabled && (
|
|
<>
|
|
{/* Required fields */}
|
|
{requiredFields.length > 0 && (
|
|
<Card className="-mx-4">
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-sm">Credentials</CardTitle>
|
|
<CardDescription>Required fields to connect this source.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{requiredFields.map(([name, field]) => (
|
|
<FieldInput
|
|
key={name}
|
|
name={name}
|
|
field={field}
|
|
value={formValues[name]}
|
|
onChange={(v) => handleFieldChange(name, v)}
|
|
disabled={busy}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Optional fields */}
|
|
{optionalFields.length > 0 && (
|
|
<Card className="-mx-4">
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-sm">Options</CardTitle>
|
|
<CardDescription>Optional configuration for this source.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}>
|
|
{optionalFields.map(([name, field]) => (
|
|
<FieldInput
|
|
key={name}
|
|
name={name}
|
|
field={field}
|
|
value={formValues[name]}
|
|
onChange={(v) => handleFieldChange(name, v)}
|
|
disabled={busy}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-3">
|
|
{serverConfig && (
|
|
<Button
|
|
onClick={() => deleteMutation.mutate()}
|
|
disabled={busy}
|
|
variant="outline"
|
|
className="text-destructive hover:text-destructive"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="size-4" />
|
|
)}
|
|
{deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
|
|
</Button>
|
|
)}
|
|
<Button onClick={() => saveMutation.mutate()} disabled={busy}>
|
|
{saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
|
{saveMutation.isPending ? "Saving…" : "Save configuration"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Always-on sources */}
|
|
{source.alwaysEnabled && source.id !== "aelis.location" && (
|
|
<>
|
|
<Separator />
|
|
<p className="text-sm text-muted-foreground">
|
|
This source is always enabled and requires no configuration.
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{source.id === "aelis.location" && <LocationCard />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LocationCard() {
|
|
const [lat, setLat] = useState("")
|
|
const [lng, setLng] = useState("")
|
|
|
|
const locationMutation = useMutation({
|
|
mutationFn: (coords: { lat: number; lng: number }) =>
|
|
pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }),
|
|
onSuccess() {
|
|
toast.success("Location updated")
|
|
},
|
|
onError(err) {
|
|
toast.error(err.message)
|
|
},
|
|
})
|
|
|
|
function handlePush() {
|
|
const latNum = parseFloat(lat)
|
|
const lngNum = parseFloat(lng)
|
|
if (isNaN(latNum) || isNaN(lngNum)) return
|
|
locationMutation.mutate({ lat: latNum, lng: lngNum })
|
|
}
|
|
|
|
function handleUseDevice() {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
setLat(String(pos.coords.latitude))
|
|
setLng(String(pos.coords.longitude))
|
|
locationMutation.mutate({
|
|
lat: pos.coords.latitude,
|
|
lng: pos.coords.longitude,
|
|
})
|
|
},
|
|
(err) => {
|
|
locationMutation.reset()
|
|
alert(`Geolocation error: ${err.message}`)
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card className="-mx-4">
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-sm">Push Location</CardTitle>
|
|
<CardDescription>Send a location update to the backend.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="loc-lat" className="text-xs font-medium">Latitude</Label>
|
|
<Input
|
|
id="loc-lat"
|
|
type="number"
|
|
step="any"
|
|
value={lat}
|
|
onChange={(e) => setLat(e.target.value)}
|
|
placeholder="51.5074"
|
|
disabled={locationMutation.isPending}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="loc-lng" className="text-xs font-medium">Longitude</Label>
|
|
<Input
|
|
id="loc-lng"
|
|
type="number"
|
|
step="any"
|
|
value={lng}
|
|
onChange={(e) => setLng(e.target.value)}
|
|
placeholder="-0.1278"
|
|
disabled={locationMutation.isPending}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleUseDevice}
|
|
disabled={locationMutation.isPending}
|
|
>
|
|
<MapPin className="size-3.5" />
|
|
Use device location
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handlePush}
|
|
disabled={locationMutation.isPending || !lat || !lng}
|
|
>
|
|
{locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
|
Push
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function FieldInput({
|
|
name,
|
|
field,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
name: string
|
|
field: ConfigFieldDef
|
|
value: unknown
|
|
onChange: (value: unknown) => void
|
|
disabled?: boolean
|
|
}) {
|
|
const labelContent = (
|
|
<div className="flex items-center gap-1.5">
|
|
<span>{field.label}</span>
|
|
{field.required && <span className="text-destructive">*</span>}
|
|
{field.description && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="size-3 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-xs text-xs">
|
|
{field.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
if (field.type === "select" && field.options) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={name} className="text-xs font-medium">
|
|
{labelContent}
|
|
</Label>
|
|
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
|
<SelectTrigger id={name}>
|
|
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{field.options.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === "multiselect" && field.options) {
|
|
const selected = Array.isArray(value) ? (value as string[]) : []
|
|
|
|
function toggle(optValue: string) {
|
|
const next = selected.includes(optValue)
|
|
? selected.filter((v) => v !== optValue)
|
|
: [...selected, optValue]
|
|
onChange(next)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">
|
|
{labelContent}
|
|
</Label>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{field.options!.map((opt) => {
|
|
const isSelected = selected.includes(opt.value)
|
|
return (
|
|
<Badge
|
|
key={opt.value}
|
|
variant={isSelected ? "default" : "outline"}
|
|
className={`cursor-pointer select-none ${isSelected ? "" : "opacity-60 hover:opacity-100"}`}
|
|
onClick={() => !disabled && toggle(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</Badge>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === "number") {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={name} className="text-xs font-medium">
|
|
{labelContent}
|
|
</Label>
|
|
<Input
|
|
id={name}
|
|
type="number"
|
|
value={value === undefined || value === null ? "" : String(value)}
|
|
onChange={(e) => {
|
|
const v = e.target.value
|
|
onChange(v === "" ? undefined : Number(v))
|
|
}}
|
|
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={name} className="text-xs font-medium">
|
|
{labelContent}
|
|
</Label>
|
|
<Input
|
|
id={name}
|
|
type={field.secret ? "password" : "text"}
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function buildInitialValues(
|
|
fields: Record<string, ConfigFieldDef>,
|
|
saved: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> {
|
|
const values: Record<string, unknown> = {}
|
|
for (const [name, field] of Object.entries(fields)) {
|
|
if (saved && name in saved) {
|
|
values[name] = saved[name]
|
|
} else if (field.defaultValue !== undefined) {
|
|
values[name] = field.defaultValue
|
|
} else if (field.type === "multiselect") {
|
|
values[name] = []
|
|
} else {
|
|
values[name] = field.type === "number" ? undefined : ""
|
|
}
|
|
}
|
|
return values
|
|
}
|