@argus-vrt/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.cta.json +15 -0
  2. package/DEPLOYMENT.md +154 -0
  3. package/Dockerfile +51 -0
  4. package/README.md +159 -0
  5. package/docker-compose.prod.yml +38 -0
  6. package/docker-compose.yml +15 -0
  7. package/drizzle/0000_slim_makkari.sql +61 -0
  8. package/drizzle/meta/0000_snapshot.json +452 -0
  9. package/drizzle/meta/_journal.json +13 -0
  10. package/drizzle.config.ts +10 -0
  11. package/package.json +60 -0
  12. package/public/favicon.ico +0 -0
  13. package/public/logo-argus.svg +8 -0
  14. package/public/logo-variants/logo-argus-a.svg +9 -0
  15. package/public/logo-variants/logo-argus-modern.svg +11 -0
  16. package/public/logo-variants/logo-argus-peacock.svg +8 -0
  17. package/public/logo192.png +0 -0
  18. package/public/logo512.png +0 -0
  19. package/public/manifest.json +25 -0
  20. package/public/robots.txt +3 -0
  21. package/public/tanstack-circle-logo.png +0 -0
  22. package/public/tanstack-word-logo-white.svg +1 -0
  23. package/scripts/backfill-kind.ts +148 -0
  24. package/src/api-plugin.ts +169 -0
  25. package/src/components/image/ImageCompare.tsx +188 -0
  26. package/src/components/story/StoryFlatList.tsx +67 -0
  27. package/src/components/story/StoryGroupedTree.tsx +273 -0
  28. package/src/components/story/StoryTree.tsx +185 -0
  29. package/src/components/ui/Drawer.tsx +110 -0
  30. package/src/components/ui/SearchInput.tsx +95 -0
  31. package/src/components/ui/StatusBadge.tsx +59 -0
  32. package/src/components/ui/ViewModeToggle.tsx +39 -0
  33. package/src/db/index.ts +27 -0
  34. package/src/db/schema.ts +151 -0
  35. package/src/hooks/useDebounce.ts +23 -0
  36. package/src/hooks/useStoryTree.ts +205 -0
  37. package/src/lib/utils.ts +55 -0
  38. package/src/logo.svg +12 -0
  39. package/src/routeTree.gen.ts +177 -0
  40. package/src/router.tsx +17 -0
  41. package/src/routes/__root.tsx +174 -0
  42. package/src/routes/branches/$name.tsx +171 -0
  43. package/src/routes/branches/index.tsx +104 -0
  44. package/src/routes/index.tsx +178 -0
  45. package/src/routes/tests/$id.tsx +417 -0
  46. package/src/routes/tests/index.tsx +128 -0
  47. package/src/routes/upload.tsx +108 -0
  48. package/src/styles.css +213 -0
  49. package/tsconfig.json +28 -0
  50. package/vite.config.ts +30 -0
@@ -0,0 +1,177 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as UploadRouteImport } from './routes/upload'
13
+ import { Route as IndexRouteImport } from './routes/index'
14
+ import { Route as TestsIndexRouteImport } from './routes/tests/index'
15
+ import { Route as BranchesIndexRouteImport } from './routes/branches/index'
16
+ import { Route as TestsIdRouteImport } from './routes/tests/$id'
17
+ import { Route as BranchesNameRouteImport } from './routes/branches/$name'
18
+
19
+ const UploadRoute = UploadRouteImport.update({
20
+ id: '/upload',
21
+ path: '/upload',
22
+ getParentRoute: () => rootRouteImport,
23
+ } as any)
24
+ const IndexRoute = IndexRouteImport.update({
25
+ id: '/',
26
+ path: '/',
27
+ getParentRoute: () => rootRouteImport,
28
+ } as any)
29
+ const TestsIndexRoute = TestsIndexRouteImport.update({
30
+ id: '/tests/',
31
+ path: '/tests/',
32
+ getParentRoute: () => rootRouteImport,
33
+ } as any)
34
+ const BranchesIndexRoute = BranchesIndexRouteImport.update({
35
+ id: '/branches/',
36
+ path: '/branches/',
37
+ getParentRoute: () => rootRouteImport,
38
+ } as any)
39
+ const TestsIdRoute = TestsIdRouteImport.update({
40
+ id: '/tests/$id',
41
+ path: '/tests/$id',
42
+ getParentRoute: () => rootRouteImport,
43
+ } as any)
44
+ const BranchesNameRoute = BranchesNameRouteImport.update({
45
+ id: '/branches/$name',
46
+ path: '/branches/$name',
47
+ getParentRoute: () => rootRouteImport,
48
+ } as any)
49
+
50
+ export interface FileRoutesByFullPath {
51
+ '/': typeof IndexRoute
52
+ '/upload': typeof UploadRoute
53
+ '/branches/$name': typeof BranchesNameRoute
54
+ '/tests/$id': typeof TestsIdRoute
55
+ '/branches/': typeof BranchesIndexRoute
56
+ '/tests/': typeof TestsIndexRoute
57
+ }
58
+ export interface FileRoutesByTo {
59
+ '/': typeof IndexRoute
60
+ '/upload': typeof UploadRoute
61
+ '/branches/$name': typeof BranchesNameRoute
62
+ '/tests/$id': typeof TestsIdRoute
63
+ '/branches': typeof BranchesIndexRoute
64
+ '/tests': typeof TestsIndexRoute
65
+ }
66
+ export interface FileRoutesById {
67
+ __root__: typeof rootRouteImport
68
+ '/': typeof IndexRoute
69
+ '/upload': typeof UploadRoute
70
+ '/branches/$name': typeof BranchesNameRoute
71
+ '/tests/$id': typeof TestsIdRoute
72
+ '/branches/': typeof BranchesIndexRoute
73
+ '/tests/': typeof TestsIndexRoute
74
+ }
75
+ export interface FileRouteTypes {
76
+ fileRoutesByFullPath: FileRoutesByFullPath
77
+ fullPaths:
78
+ | '/'
79
+ | '/upload'
80
+ | '/branches/$name'
81
+ | '/tests/$id'
82
+ | '/branches/'
83
+ | '/tests/'
84
+ fileRoutesByTo: FileRoutesByTo
85
+ to:
86
+ | '/'
87
+ | '/upload'
88
+ | '/branches/$name'
89
+ | '/tests/$id'
90
+ | '/branches'
91
+ | '/tests'
92
+ id:
93
+ | '__root__'
94
+ | '/'
95
+ | '/upload'
96
+ | '/branches/$name'
97
+ | '/tests/$id'
98
+ | '/branches/'
99
+ | '/tests/'
100
+ fileRoutesById: FileRoutesById
101
+ }
102
+ export interface RootRouteChildren {
103
+ IndexRoute: typeof IndexRoute
104
+ UploadRoute: typeof UploadRoute
105
+ BranchesNameRoute: typeof BranchesNameRoute
106
+ TestsIdRoute: typeof TestsIdRoute
107
+ BranchesIndexRoute: typeof BranchesIndexRoute
108
+ TestsIndexRoute: typeof TestsIndexRoute
109
+ }
110
+
111
+ declare module '@tanstack/react-router' {
112
+ interface FileRoutesByPath {
113
+ '/upload': {
114
+ id: '/upload'
115
+ path: '/upload'
116
+ fullPath: '/upload'
117
+ preLoaderRoute: typeof UploadRouteImport
118
+ parentRoute: typeof rootRouteImport
119
+ }
120
+ '/': {
121
+ id: '/'
122
+ path: '/'
123
+ fullPath: '/'
124
+ preLoaderRoute: typeof IndexRouteImport
125
+ parentRoute: typeof rootRouteImport
126
+ }
127
+ '/tests/': {
128
+ id: '/tests/'
129
+ path: '/tests'
130
+ fullPath: '/tests/'
131
+ preLoaderRoute: typeof TestsIndexRouteImport
132
+ parentRoute: typeof rootRouteImport
133
+ }
134
+ '/branches/': {
135
+ id: '/branches/'
136
+ path: '/branches'
137
+ fullPath: '/branches/'
138
+ preLoaderRoute: typeof BranchesIndexRouteImport
139
+ parentRoute: typeof rootRouteImport
140
+ }
141
+ '/tests/$id': {
142
+ id: '/tests/$id'
143
+ path: '/tests/$id'
144
+ fullPath: '/tests/$id'
145
+ preLoaderRoute: typeof TestsIdRouteImport
146
+ parentRoute: typeof rootRouteImport
147
+ }
148
+ '/branches/$name': {
149
+ id: '/branches/$name'
150
+ path: '/branches/$name'
151
+ fullPath: '/branches/$name'
152
+ preLoaderRoute: typeof BranchesNameRouteImport
153
+ parentRoute: typeof rootRouteImport
154
+ }
155
+ }
156
+ }
157
+
158
+ const rootRouteChildren: RootRouteChildren = {
159
+ IndexRoute: IndexRoute,
160
+ UploadRoute: UploadRoute,
161
+ BranchesNameRoute: BranchesNameRoute,
162
+ TestsIdRoute: TestsIdRoute,
163
+ BranchesIndexRoute: BranchesIndexRoute,
164
+ TestsIndexRoute: TestsIndexRoute,
165
+ }
166
+ export const routeTree = rootRouteImport
167
+ ._addFileChildren(rootRouteChildren)
168
+ ._addFileTypes<FileRouteTypes>()
169
+
170
+ import type { getRouter } from './router.tsx'
171
+ import type { createStart } from '@tanstack/react-start'
172
+ declare module '@tanstack/react-start' {
173
+ interface Register {
174
+ ssr: true
175
+ router: Awaited<ReturnType<typeof getRouter>>
176
+ }
177
+ }
package/src/router.tsx ADDED
@@ -0,0 +1,17 @@
1
+ import { createRouter } from '@tanstack/react-router'
2
+
3
+ // Import the generated route tree
4
+ import { routeTree } from './routeTree.gen'
5
+
6
+ // Create a new router instance
7
+ export const getRouter = () => {
8
+ const router = createRouter({
9
+ routeTree,
10
+ context: {},
11
+
12
+ scrollRestoration: true,
13
+ defaultPreloadStaleTime: 0,
14
+ })
15
+
16
+ return router
17
+ }
@@ -0,0 +1,174 @@
1
+ import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'
2
+ import { useState, useEffect } from 'react'
3
+ import { Sun, Moon, Menu, X } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+
6
+ import appCss from '../styles.css?url'
7
+
8
+ export const Route = createRootRoute({
9
+ head: () => ({
10
+ meta: [
11
+ { charSet: 'utf-8' },
12
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
13
+ { title: 'Argus' },
14
+ ],
15
+ links: [
16
+ { rel: 'icon', type: 'image/svg+xml', href: '/logo-argus.svg' },
17
+ { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
18
+ { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
19
+ { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap' },
20
+ { rel: 'stylesheet', href: appCss },
21
+ ],
22
+ }),
23
+ component: RootComponent,
24
+ })
25
+
26
+ function RootComponent() {
27
+ const [darkMode, setDarkMode] = useState(false)
28
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
29
+
30
+ useEffect(() => {
31
+ // Check localStorage and system preference on mount
32
+ const stored = localStorage.getItem('darkMode')
33
+ if (stored !== null) {
34
+ setDarkMode(stored === 'true')
35
+ } else {
36
+ setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
37
+ }
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ // Update class and localStorage when darkMode changes
42
+ if (darkMode) {
43
+ document.documentElement.classList.add('dark')
44
+ } else {
45
+ document.documentElement.classList.remove('dark')
46
+ }
47
+ localStorage.setItem('darkMode', String(darkMode))
48
+ }, [darkMode])
49
+
50
+ // Close mobile menu on escape
51
+ useEffect(() => {
52
+ const handleEscape = (e: KeyboardEvent) => {
53
+ if (e.key === 'Escape') {
54
+ setMobileMenuOpen(false)
55
+ }
56
+ }
57
+ document.addEventListener('keydown', handleEscape)
58
+ return () => document.removeEventListener('keydown', handleEscape)
59
+ }, [])
60
+
61
+ // Lock body scroll when mobile menu is open
62
+ useEffect(() => {
63
+ if (mobileMenuOpen) {
64
+ document.body.style.overflow = 'hidden'
65
+ } else {
66
+ document.body.style.overflow = ''
67
+ }
68
+ return () => {
69
+ document.body.style.overflow = ''
70
+ }
71
+ }, [mobileMenuOpen])
72
+
73
+ const navLinks = [
74
+ { href: '/tests', label: 'Tests' },
75
+ { href: '/branches', label: 'Branches' },
76
+ ]
77
+
78
+ return (
79
+ <html lang="en">
80
+ <head>
81
+ <HeadContent />
82
+ </head>
83
+ <body className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
84
+ <nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
85
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
86
+ <div className="flex justify-between h-16">
87
+ {/* Logo */}
88
+ <div className="flex items-center">
89
+ <a href="/" className="flex items-center gap-2.5">
90
+ <img src="/logo-argus.svg" alt="Argus" className="w-8 h-8" />
91
+ <div className="flex flex-col">
92
+ <span className="font-heading font-semibold text-gray-900 dark:text-white">
93
+ Argus
94
+ </span>
95
+ <span className="text-xs text-gray-500 dark:text-gray-400 -mt-0.5 hidden sm:block">
96
+ Visual Regression Testing
97
+ </span>
98
+ </div>
99
+ </a>
100
+ </div>
101
+
102
+ {/* Desktop navigation */}
103
+ <div className="hidden md:flex items-center gap-4">
104
+ {navLinks.map((link) => (
105
+ <a
106
+ key={link.href}
107
+ href={link.href}
108
+ className="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 text-sm font-medium transition-colors"
109
+ >
110
+ {link.label}
111
+ </a>
112
+ ))}
113
+ <button
114
+ onClick={() => setDarkMode(!darkMode)}
115
+ className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
116
+ title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
117
+ >
118
+ {darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
119
+ </button>
120
+ </div>
121
+
122
+ {/* Mobile menu button */}
123
+ <div className="flex items-center gap-2 md:hidden">
124
+ <button
125
+ onClick={() => setDarkMode(!darkMode)}
126
+ className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
127
+ title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
128
+ >
129
+ {darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
130
+ </button>
131
+ <button
132
+ onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
133
+ className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
134
+ aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
135
+ >
136
+ {mobileMenuOpen ? (
137
+ <X className="w-6 h-6" />
138
+ ) : (
139
+ <Menu className="w-6 h-6" />
140
+ )}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ {/* Mobile menu */}
147
+ <div
148
+ className={cn(
149
+ 'md:hidden overflow-hidden transition-all duration-300 ease-in-out',
150
+ mobileMenuOpen ? 'max-h-48 opacity-100' : 'max-h-0 opacity-0'
151
+ )}
152
+ >
153
+ <div className="px-4 py-2 space-y-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
154
+ {navLinks.map((link) => (
155
+ <a
156
+ key={link.href}
157
+ href={link.href}
158
+ onClick={() => setMobileMenuOpen(false)}
159
+ className="block px-3 py-2 text-base font-medium text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
160
+ >
161
+ {link.label}
162
+ </a>
163
+ ))}
164
+ </div>
165
+ </div>
166
+ </nav>
167
+ <main>
168
+ <Outlet />
169
+ </main>
170
+ <Scripts />
171
+ </body>
172
+ </html>
173
+ )
174
+ }
@@ -0,0 +1,171 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { createServerFn } from '@tanstack/react-start'
3
+ import { ArrowLeft, GitBranch, Hash } from 'lucide-react'
4
+ import { eq, desc } from 'drizzle-orm'
5
+ import { getDb, tests } from '../../db'
6
+ import { formatRelativeTime } from '../../lib/utils'
7
+ import { StatusBadge } from '../../components/ui/StatusBadge'
8
+
9
+ const getBranchTests = createServerFn({ method: 'GET' })
10
+ .inputValidator((data: { name: string }) => data)
11
+ .handler(async ({ data }) => {
12
+ const branchName = decodeURIComponent(data.name)
13
+ const db = getDb()
14
+
15
+ const branchTests = await db
16
+ .select()
17
+ .from(tests)
18
+ .where(eq(tests.branch, branchName))
19
+ .orderBy(desc(tests.createdAt))
20
+
21
+ return {
22
+ branchName,
23
+ tests: branchTests.map((t) => ({
24
+ ...t,
25
+ createdAt: t.createdAt.toISOString(),
26
+ })),
27
+ }
28
+ })
29
+
30
+ export const Route = createFileRoute('/branches/$name')({
31
+ component: BranchDetail,
32
+ loader: ({ params }) => getBranchTests({ data: { name: params.name } }),
33
+ })
34
+
35
+ function BranchDetail() {
36
+ const { branchName, tests } = Route.useLoaderData()
37
+
38
+ const stats = {
39
+ total: tests.length,
40
+ pending: tests.filter((t) => t.status === 'PENDING').length,
41
+ approved: tests.filter((t) => t.status === 'APPROVED').length,
42
+ rejected: tests.filter((t) => t.status === 'REJECTED').length,
43
+ }
44
+
45
+ return (
46
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
47
+ <div className="mb-6">
48
+ <Link
49
+ to="/branches"
50
+ className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-4"
51
+ >
52
+ <ArrowLeft className="w-4 h-4" />
53
+ Back to Branches
54
+ </Link>
55
+
56
+ <div className="flex items-center gap-3">
57
+ <GitBranch className="w-6 h-6 text-gray-400" />
58
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white truncate">{branchName}</h1>
59
+ </div>
60
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
61
+ {stats.total} test{stats.total !== 1 ? 's' : ''} on this branch
62
+ </p>
63
+ </div>
64
+
65
+ {/* Stats Cards */}
66
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
67
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
68
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Tests</div>
69
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
70
+ {stats.total}
71
+ </div>
72
+ </div>
73
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
74
+ <div className="text-sm font-medium text-warning-600 dark:text-warning-400">Pending</div>
75
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-warning-600 dark:text-warning-400">
76
+ {stats.pending}
77
+ </div>
78
+ </div>
79
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
80
+ <div className="text-sm font-medium text-success-600 dark:text-success-400">Approved</div>
81
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-success-600 dark:text-success-400">
82
+ {stats.approved}
83
+ </div>
84
+ </div>
85
+ <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 sm:p-6">
86
+ <div className="text-sm font-medium text-error-600 dark:text-error-400">Rejected</div>
87
+ <div className="mt-2 text-2xl sm:text-3xl font-bold text-error-600 dark:text-error-400">
88
+ {stats.rejected}
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ {/* Tests List */}
94
+ <div className="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
95
+ <div className="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
96
+ <h2 className="text-lg font-medium text-gray-900 dark:text-white">Test History</h2>
97
+ </div>
98
+ <div className="overflow-x-auto">
99
+ <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
100
+ <thead className="bg-gray-50 dark:bg-gray-900">
101
+ <tr>
102
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
103
+ Commit
104
+ </th>
105
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
106
+ Status
107
+ </th>
108
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider hidden md:table-cell">
109
+ Changes
110
+ </th>
111
+ <th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider hidden lg:table-cell">
112
+ Created
113
+ </th>
114
+ <th className="px-4 sm:px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
115
+ Actions
116
+ </th>
117
+ </tr>
118
+ </thead>
119
+ <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
120
+ {tests.map((test) => (
121
+ <tr key={test.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
122
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap">
123
+ <div className="flex items-center gap-2">
124
+ <Hash className="w-4 h-4 text-gray-400 flex-shrink-0" />
125
+ <span className="text-sm font-mono text-gray-600 dark:text-gray-300">
126
+ {test.commitHash}
127
+ </span>
128
+ </div>
129
+ <div className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-xs truncate">
130
+ {test.commitMessage}
131
+ </div>
132
+ </td>
133
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap">
134
+ <StatusBadge status={test.status} />
135
+ </td>
136
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm hidden md:table-cell">
137
+ <div className="flex items-center gap-4">
138
+ <span className="text-warning-600 dark:text-warning-400">
139
+ {test.changedCount} changed
140
+ </span>
141
+ <span className="text-success-600 dark:text-success-400">
142
+ {test.passedCount} passed
143
+ </span>
144
+ {test.failedCount > 0 && (
145
+ <span className="text-error-600 dark:text-error-400">
146
+ {test.failedCount} failed
147
+ </span>
148
+ )}
149
+ </div>
150
+ </td>
151
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden lg:table-cell">
152
+ {formatRelativeTime(test.createdAt)}
153
+ </td>
154
+ <td className="px-4 sm:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
155
+ <Link
156
+ to="/tests/$id"
157
+ params={{ id: test.id }}
158
+ className="text-primary-600 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-300"
159
+ >
160
+ View Details
161
+ </Link>
162
+ </td>
163
+ </tr>
164
+ ))}
165
+ </tbody>
166
+ </table>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ )
171
+ }
@@ -0,0 +1,104 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { createServerFn } from '@tanstack/react-start'
3
+ import { GitBranch, ChevronRight } from 'lucide-react'
4
+ import { sql, desc } from 'drizzle-orm'
5
+ import { getDb, tests } from '../../db'
6
+ import { formatRelativeTime } from '../../lib/utils'
7
+ import { StatusIcon } from '../../components/ui/StatusBadge'
8
+
9
+ const getBranches = createServerFn({ method: 'GET' }).handler(async () => {
10
+ const db = getDb()
11
+
12
+ // Get unique branches with their latest test
13
+ const branchData = await db
14
+ .select({
15
+ branch: tests.branch,
16
+ testCount: sql<number>`count(*)`.as('test_count'),
17
+ })
18
+ .from(tests)
19
+ .groupBy(tests.branch)
20
+
21
+ // Get the latest test for each branch
22
+ const branches = await Promise.all(
23
+ branchData.map(async (b) => {
24
+ const [latestTest] = await db
25
+ .select()
26
+ .from(tests)
27
+ .where(sql`${tests.branch} = ${b.branch}`)
28
+ .orderBy(desc(tests.createdAt))
29
+ .limit(1)
30
+
31
+ return {
32
+ name: b.branch,
33
+ lastTest: latestTest
34
+ ? {
35
+ id: latestTest.id,
36
+ status: latestTest.status,
37
+ commitHash: latestTest.commitHash,
38
+ createdAt: latestTest.createdAt.toISOString(),
39
+ }
40
+ : null,
41
+ testCount: Number(b.testCount),
42
+ }
43
+ })
44
+ )
45
+
46
+ return branches.filter((b) => b.lastTest !== null)
47
+ })
48
+
49
+ export const Route = createFileRoute('/branches/')({
50
+ component: BranchesIndex,
51
+ loader: () => getBranches(),
52
+ })
53
+
54
+ function BranchesIndex() {
55
+ const branches = Route.useLoaderData()
56
+
57
+ return (
58
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
59
+ <div className="mb-8">
60
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Branches</h1>
61
+ <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
62
+ View visual testing status by branch
63
+ </p>
64
+ </div>
65
+
66
+ <div className="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
67
+ <ul className="divide-y divide-gray-200 dark:divide-gray-700">
68
+ {branches.map((branch) => (
69
+ <li key={branch.name}>
70
+ <Link
71
+ to="/branches/$name"
72
+ params={{ name: encodeURIComponent(branch.name) }}
73
+ className="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
74
+ >
75
+ <div className="px-4 sm:px-6 py-4 flex items-center justify-between">
76
+ <div className="flex items-center gap-3 sm:gap-4 min-w-0 flex-1">
77
+ <GitBranch className="w-5 h-5 text-gray-400 flex-shrink-0" />
78
+ <div className="min-w-0 flex-1">
79
+ <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
80
+ {branch.name}
81
+ </div>
82
+ <div className="text-sm text-gray-500 dark:text-gray-400">
83
+ {branch.testCount} test
84
+ {branch.testCount !== 1 ? 's' : ''} &middot; Last run{' '}
85
+ {formatRelativeTime(branch.lastTest!.createdAt)}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ <div className="flex items-center gap-2 sm:gap-4 flex-shrink-0 ml-2">
90
+ <StatusIcon status={branch.lastTest!.status} />
91
+ <span className="text-sm text-gray-500 dark:text-gray-400 font-mono hidden sm:inline">
92
+ {branch.lastTest!.commitHash}
93
+ </span>
94
+ <ChevronRight className="w-5 h-5 text-gray-400" />
95
+ </div>
96
+ </div>
97
+ </Link>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ </div>
102
+ </div>
103
+ )
104
+ }