diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ef0eea75..6e495e9b 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { signOut, useSession } from "next-auth/react"; import { useQuery } from "@tanstack/react-query"; -import { LogOut, User } from "lucide-react"; +import { LogOut, ShieldAlert, User } from "lucide-react"; import { useTRPC } from "@/trpc/client"; import { AppSidebar } from "@/components/app-sidebar"; @@ -51,6 +51,10 @@ export default function DashboardLayout({ return "U"; })(); + const teamsQuery = useQuery(trpc.team.list.queryOptions()); + const teams = teamsQuery.data ?? []; + const isTeamless = teamsQuery.isSuccess && teams.length === 0; + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); // Force password change dialog when mustChangePassword is set useEffect(() => { @@ -68,6 +72,64 @@ export default function DashboardLayout({ } }, [me, router]); + if (isTeamless) { + return ( +
+
+
+ + + + + + + +
+

{userName ?? "User"}

+ {userEmail && ( +

{userEmail}

+ )} +
+
+ + signOut({ callbackUrl: "/login" })}> + + Sign out + +
+
+
+
+
+
+
+ +
+

No Team Assigned

+

+ Your account is active but you haven't been assigned to a team yet. Contact your administrator to get access. +

+ {(userName || userEmail) && ( +

+ Signed in as {userName || userEmail} +

+ )} + +
+
+ +
+ ); + } + return ( diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index f0b76672..8f793368 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -467,7 +467,32 @@ function AuthSettings() { testOidcMutation.mutate({ issuer }); }; - const [teamMappings, setTeamMappings] = useState>([]); + const [teamMappings, setTeamMappings] = useState>([]); + + function mergeMappings( + flat: Array<{ group: string; teamId: string; role: string }> + ): Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> { + const map = new Map(); + for (const m of flat) { + const key = `${m.group}::${m.role}`; + const existing = map.get(key); + if (existing) { + existing.teamIds.push(m.teamId); + } else { + map.set(key, { group: m.group, teamIds: [m.teamId], role: m.role as "VIEWER" | "EDITOR" | "ADMIN" }); + } + } + return [...map.values()]; + } + + function flattenMappings( + grouped: Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> + ): Array<{ group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN" }> { + return grouped.flatMap((row) => + row.teamIds.map((teamId) => ({ group: row.group, teamId, role: row.role })) + ); + } + const [defaultTeamId, setDefaultTeamId] = useState(""); const [defaultRole, setDefaultRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER"); const [groupSyncEnabled, setGroupSyncEnabled] = useState(false); @@ -483,7 +508,9 @@ function AuthSettings() { setGroupSyncEnabled(settings.oidcGroupSyncEnabled ?? false); setGroupsScope(settings.oidcGroupsScope ?? ""); setGroupsClaim(settings.oidcGroupsClaim ?? "groups"); - setTeamMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN"}>); + setTeamMappings( + mergeMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: string}>) + ); setDefaultTeamId(settings.oidcDefaultTeamId ?? ""); }, [settings, isDirty]); @@ -504,7 +531,7 @@ function AuthSettings() { function addMapping() { markDirty(); - setTeamMappings([...teamMappings, { group: "", teamId: "", role: "VIEWER" }]); + setTeamMappings([...teamMappings, { group: "", teamIds: [], role: "VIEWER" }]); } function removeMapping(index: number) { @@ -512,10 +539,17 @@ function AuthSettings() { setTeamMappings(teamMappings.filter((_, i) => i !== index)); } - function updateMapping(index: number, field: keyof typeof teamMappings[number], value: string) { + function updateMapping(index: number, field: "group" | "role", value: string) { + markDirty(); + setTeamMappings(teamMappings.map((m, i) => + i === index ? { ...m, [field]: value } : m + )); + } + + function updateMappingTeams(index: number, teamIds: string[]) { markDirty(); setTeamMappings(teamMappings.map((m, i) => - i === index ? { ...m, [field]: value } as typeof m : m + i === index ? { ...m, teamIds } : m )); } @@ -688,7 +722,7 @@ function AuthSettings() {
{ e.preventDefault(); updateTeamMappingMutation.mutate({ - mappings: teamMappings.filter((m) => m.group && m.teamId), + mappings: flattenMappings(teamMappings).filter((m) => m.group && m.teamId), defaultTeamId: defaultTeamId || undefined, defaultRole, groupSyncEnabled, @@ -762,19 +796,57 @@ function AuthSettings() { /> - + + + + + +
+ {(teamsQuery.data ?? []).map((team) => { + const checked = mapping.teamIds.includes(team.id); + return ( + + ); + })} +
+
+
)} +
+
+ + setSearchTerm(e.target.value)} + placeholder="Search logs..." + className="h-6 w-[180px] pl-7 text-xs bg-transparent border-border/40" + /> +
- {displayItems.length} lines + {searchTerm + ? `${filteredItems.length}/${displayItems.length} lines` + : `${displayItems.length} lines`} {logsQuery.hasNextPage && (
diff --git a/src/components/log-search-utils.tsx b/src/components/log-search-utils.tsx new file mode 100644 index 00000000..48546e92 --- /dev/null +++ b/src/components/log-search-utils.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export function highlightMatch(text: string, search: string): ReactNode { + if (!search) return text; + const idx = text.toLowerCase().indexOf(search.toLowerCase()); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + search.length)} + {text.slice(idx + search.length)} + + ); +} diff --git a/src/components/pipeline/pipeline-logs.tsx b/src/components/pipeline/pipeline-logs.tsx index 9d7e9b8a..b7c09836 100644 --- a/src/components/pipeline/pipeline-logs.tsx +++ b/src/components/pipeline/pipeline-logs.tsx @@ -2,8 +2,11 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { Search } from "lucide-react"; import { useTRPC } from "@/trpc/client"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { highlightMatch } from "@/components/log-search-utils"; import type { LogLevel } from "@/generated/prisma"; const ALL_LEVELS: LogLevel[] = ["ERROR", "WARN", "INFO", "DEBUG", "TRACE"]; @@ -46,6 +49,7 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { const [activeLevels, setActiveLevels] = useState>( new Set(ALL_LEVELS), ); + const [searchTerm, setSearchTerm] = useState(""); const queryInput = { pipelineId, @@ -65,6 +69,11 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { // All items come back newest-first from the API; reverse for chronological display const allItems = logsQuery.data?.pages.flatMap((page) => page.items) ?? []; const displayItems = [...allItems].reverse(); + const filteredItems = searchTerm + ? displayItems.filter((log) => + log.message.toLowerCase().includes(searchTerm.toLowerCase()), + ) + : displayItems; // Auto-scroll to bottom when new logs arrive useEffect(() => { @@ -125,9 +134,21 @@ export function PipelineLogs({ pipelineId, nodeId }: PipelineLogsProps) { {level} ))} +
+
+ + setSearchTerm(e.target.value)} + placeholder="Search logs..." + className="h-6 w-[180px] pl-7 text-xs bg-transparent border-border/40" + /> +
- {displayItems.length} lines + {searchTerm + ? `${filteredItems.length}/${displayItems.length} lines` + : `${displayItems.length} lines`} {logsQuery.hasNextPage && (
diff --git a/src/server/services/backup.ts b/src/server/services/backup.ts index a04f9303..24ff3b97 100644 --- a/src/server/services/backup.ts +++ b/src/server/services/backup.ts @@ -235,13 +235,17 @@ export async function listBackups(): Promise< for (const metaFile of metaFiles) { try { + const dumpFilename = metaFile.replace(/\.meta\.json$/, ".dump"); + const dumpPath = path.join(BACKUP_DIR, dumpFilename); + + // Skip orphaned .meta.json files where the .dump is missing + await fs.access(dumpPath); + const raw = await fs.readFile(path.join(BACKUP_DIR, metaFile), "utf-8"); const meta = JSON.parse(raw) as BackupMetadata; - // Derive dump filename from meta filename - const dumpFilename = metaFile.replace(/\.meta\.json$/, ".dump"); results.push({ ...meta, filename: dumpFilename }); } catch { - // skip unparseable metadata files + // skip: either .dump missing or unparseable metadata } }