@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.
- package/.cta.json +15 -0
- package/DEPLOYMENT.md +154 -0
- package/Dockerfile +51 -0
- package/README.md +159 -0
- package/docker-compose.prod.yml +38 -0
- package/docker-compose.yml +15 -0
- package/drizzle/0000_slim_makkari.sql +61 -0
- package/drizzle/meta/0000_snapshot.json +452 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +60 -0
- package/public/favicon.ico +0 -0
- package/public/logo-argus.svg +8 -0
- package/public/logo-variants/logo-argus-a.svg +9 -0
- package/public/logo-variants/logo-argus-modern.svg +11 -0
- package/public/logo-variants/logo-argus-peacock.svg +8 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/backfill-kind.ts +148 -0
- package/src/api-plugin.ts +169 -0
- package/src/components/image/ImageCompare.tsx +188 -0
- package/src/components/story/StoryFlatList.tsx +67 -0
- package/src/components/story/StoryGroupedTree.tsx +273 -0
- package/src/components/story/StoryTree.tsx +185 -0
- package/src/components/ui/Drawer.tsx +110 -0
- package/src/components/ui/SearchInput.tsx +95 -0
- package/src/components/ui/StatusBadge.tsx +59 -0
- package/src/components/ui/ViewModeToggle.tsx +39 -0
- package/src/db/index.ts +27 -0
- package/src/db/schema.ts +151 -0
- package/src/hooks/useDebounce.ts +23 -0
- package/src/hooks/useStoryTree.ts +205 -0
- package/src/lib/utils.ts +55 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +177 -0
- package/src/router.tsx +17 -0
- package/src/routes/__root.tsx +174 -0
- package/src/routes/branches/$name.tsx +171 -0
- package/src/routes/branches/index.tsx +104 -0
- package/src/routes/index.tsx +178 -0
- package/src/routes/tests/$id.tsx +417 -0
- package/src/routes/tests/index.tsx +128 -0
- package/src/routes/upload.tsx +108 -0
- package/src/styles.css +213 -0
- package/tsconfig.json +28 -0
- 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' : ''} · 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
|
+
}
|