Skip to content

Commit 6687918

Browse files
Merge pull request #17 from michelroegl-brunner/feature/repository-status-update
feat: Add repository status and update functionality
2 parents 067a7d6 + a5975a9 commit 6687918

File tree

13 files changed

+714
-158
lines changed

13 files changed

+714
-158
lines changed

src/app/__tests__/page.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
22
import { render, screen, fireEvent } from '@testing-library/react'
33
import Home from '../page'
44

5+
// Mock tRPC
6+
vi.mock('~/trpc/react', () => ({
7+
api: {
8+
scripts: {
9+
getRepoStatus: {
10+
useQuery: vi.fn(() => ({
11+
data: { isRepo: true, isBehind: false, branch: 'main', lastCommit: 'abc123' },
12+
refetch: vi.fn(),
13+
})),
14+
},
15+
getScriptCards: {
16+
useQuery: vi.fn(() => ({
17+
data: { success: true, cards: [] },
18+
isLoading: false,
19+
error: null,
20+
})),
21+
},
22+
getCtScripts: {
23+
useQuery: vi.fn(() => ({
24+
data: { scripts: [] },
25+
isLoading: false,
26+
error: null,
27+
})),
28+
},
29+
getScriptBySlug: {
30+
useQuery: vi.fn(() => ({
31+
data: null,
32+
})),
33+
},
34+
fullUpdateRepo: {
35+
useMutation: vi.fn(() => ({
36+
mutate: vi.fn(),
37+
})),
38+
},
39+
},
40+
},
41+
}))
42+
543
// Mock child components
644
vi.mock('../_components/ScriptsGrid', () => ({
745
ScriptsGrid: ({ onInstallScript }: { onInstallScript?: (path: string, name: string) => void }) => (
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { api } from '~/trpc/react';
5+
6+
export function RepoStatusButton() {
7+
const [isUpdating, setIsUpdating] = useState(false);
8+
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
9+
const [updateSteps, setUpdateSteps] = useState<string[]>([]);
10+
const [showSteps, setShowSteps] = useState(false);
11+
12+
// Query repository status
13+
const { data: repoStatus, refetch: refetchStatus } = api.scripts.getRepoStatus.useQuery();
14+
15+
// Full update mutation
16+
const fullUpdateMutation = api.scripts.fullUpdateRepo.useMutation({
17+
onSuccess: (data) => {
18+
setIsUpdating(false);
19+
setUpdateMessage(data.message);
20+
setUpdateSteps(data.steps);
21+
setShowSteps(true);
22+
23+
if (data.success) {
24+
// Refetch status after successful update
25+
setTimeout(() => {
26+
void refetchStatus();
27+
}, 1000);
28+
29+
// Clear message after 5 seconds for success
30+
setTimeout(() => {
31+
setUpdateMessage(null);
32+
setShowSteps(false);
33+
}, 5000);
34+
} else {
35+
// Clear message after 10 seconds for errors
36+
setTimeout(() => {
37+
setUpdateMessage(null);
38+
setShowSteps(false);
39+
}, 10000);
40+
}
41+
},
42+
onError: (error) => {
43+
setIsUpdating(false);
44+
setUpdateMessage(`Error: ${error.message}`);
45+
setUpdateSteps([`❌ ${error.message}`]);
46+
setShowSteps(true);
47+
setTimeout(() => {
48+
setUpdateMessage(null);
49+
setShowSteps(false);
50+
}, 10000);
51+
},
52+
});
53+
54+
const handleFullUpdate = async () => {
55+
setIsUpdating(true);
56+
setUpdateMessage(null);
57+
setUpdateSteps([]);
58+
setShowSteps(false);
59+
fullUpdateMutation.mutate();
60+
};
61+
62+
const getStatusColor = () => {
63+
if (!repoStatus?.isRepo) return 'text-gray-500';
64+
if (repoStatus.isBehind) return 'text-orange-500';
65+
return 'text-green-500';
66+
};
67+
68+
const getStatusIcon = () => {
69+
if (!repoStatus?.isRepo) return '❓';
70+
if (repoStatus.isBehind) return '⚠️';
71+
return '✅';
72+
};
73+
74+
const getStatusText = () => {
75+
if (!repoStatus?.isRepo) return 'Not a git repository';
76+
if (repoStatus.isBehind) return 'Updates available';
77+
return 'Up to date';
78+
};
79+
80+
return (
81+
<div className="flex flex-col space-y-4">
82+
{/* Status Display */}
83+
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
84+
<div className="flex items-center space-x-3">
85+
<div className="flex items-center space-x-2">
86+
<span className="text-2xl">{getStatusIcon()}</span>
87+
<div>
88+
<div className={`font-medium ${getStatusColor()}`}>
89+
Repository Status: {getStatusText()}
90+
</div>
91+
{repoStatus?.isRepo && (
92+
<div className="text-sm text-gray-500">
93+
Branch: {repoStatus.branch ?? 'unknown'} |
94+
Last commit: {repoStatus.lastCommit ? repoStatus.lastCommit.substring(0, 8) : 'unknown'}
95+
</div>
96+
)}
97+
</div>
98+
</div>
99+
</div>
100+
101+
<div className="flex items-center space-x-3">
102+
{repoStatus?.isBehind && (
103+
<button
104+
onClick={handleFullUpdate}
105+
disabled={isUpdating}
106+
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
107+
isUpdating
108+
? 'bg-gray-400 text-white cursor-not-allowed'
109+
: 'bg-orange-600 text-white hover:bg-orange-700'
110+
}`}
111+
>
112+
{isUpdating ? (
113+
<>
114+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
115+
<span>Updating...</span>
116+
</>
117+
) : (
118+
<>
119+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
121+
</svg>
122+
<span>Update Repository</span>
123+
</>
124+
)}
125+
</button>
126+
)}
127+
128+
<button
129+
onClick={() => refetchStatus()}
130+
className="flex items-center space-x-2 px-3 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
131+
title="Refresh status"
132+
>
133+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
134+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
135+
</svg>
136+
</button>
137+
</div>
138+
</div>
139+
140+
{/* Update Message */}
141+
{updateMessage && (
142+
<div className={`p-4 rounded-lg ${
143+
updateMessage.includes('Error') || updateMessage.includes('Failed')
144+
? 'bg-red-100 text-red-700 border border-red-200'
145+
: 'bg-green-100 text-green-700 border border-green-200'
146+
}`}>
147+
<div className="font-medium mb-2">{updateMessage}</div>
148+
{showSteps && updateSteps.length > 0 && (
149+
<div className="mt-3">
150+
<button
151+
onClick={() => setShowSteps(!showSteps)}
152+
className="text-sm font-medium hover:underline"
153+
>
154+
{showSteps ? 'Hide' : 'Show'} update steps
155+
</button>
156+
{showSteps && (
157+
<div className="mt-2 space-y-1">
158+
{updateSteps.map((step, index) => (
159+
<div key={index} className="text-sm font-mono">
160+
{step}
161+
</div>
162+
))}
163+
</div>
164+
)}
165+
</div>
166+
)}
167+
</div>
168+
)}
169+
170+
{/* Update Steps (always show when updating) */}
171+
{isUpdating && updateSteps.length > 0 && (
172+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
173+
<div className="font-medium text-blue-800 mb-2">Update Progress:</div>
174+
<div className="space-y-1">
175+
{updateSteps.map((step, index) => (
176+
<div key={index} className="text-sm font-mono text-blue-700">
177+
{step}
178+
</div>
179+
))}
180+
</div>
181+
</div>
182+
)}
183+
</div>
184+
);
185+
}

src/app/_components/ScriptDetailModal.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
2323
const [textViewerOpen, setTextViewerOpen] = useState(false);
2424

2525
// Check if script files exist locally
26-
const { data: scriptFilesData, refetch: refetchScriptFiles } = api.scripts.checkScriptFiles.useQuery(
26+
const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery(
2727
{ slug: script?.slug ?? '' },
2828
{ enabled: !!script && isOpen }
2929
);
3030

31-
// Compare local and remote script content
32-
const { data: comparisonData, refetch: refetchComparison } = api.scripts.compareScriptContent.useQuery(
31+
// Compare local and remote script content (run in parallel, not dependent on scriptFilesData)
32+
const { data: comparisonData, refetch: refetchComparison, isLoading: comparisonLoading } = api.scripts.compareScriptContent.useQuery(
3333
{ slug: script?.slug ?? '' },
34-
{ enabled: !!script && isOpen && scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) }
34+
{ enabled: !!script && isOpen }
3535
);
3636

3737
// Load script mutation
@@ -81,10 +81,10 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
8181
const handleInstallScript = () => {
8282
if (!script || !onInstallScript) return;
8383

84-
// Find the CT script path
85-
const ctScript = script.install_methods?.find(method => method.script?.startsWith('ct/'));
86-
if (ctScript?.script) {
87-
const scriptPath = `scripts/${ctScript.script}`;
84+
// Find the script path (CT or tools)
85+
const scriptMethod = script.install_methods?.find(method => method.script);
86+
if (scriptMethod?.script) {
87+
const scriptPath = `scripts/${scriptMethod.script}`;
8888
const scriptName = script.name;
8989
onInstallScript(scriptPath, scriptName);
9090
onClose(); // Close the modal when starting installation
@@ -270,7 +270,16 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
270270
)}
271271

272272
{/* Script Files Status */}
273-
{scriptFilesData?.success && (
273+
{(scriptFilesLoading || comparisonLoading) && (
274+
<div className="mx-6 mb-4 p-3 rounded-lg bg-blue-50 text-sm">
275+
<div className="flex items-center space-x-2">
276+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
277+
<span>Loading script status...</span>
278+
</div>
279+
</div>
280+
)}
281+
282+
{scriptFilesData?.success && !scriptFilesLoading && (
274283
<div className="mx-6 mb-4 p-3 rounded-lg bg-gray-50 text-sm">
275284
<div className="flex items-center space-x-4">
276285
<div className="flex items-center space-x-2">
@@ -281,7 +290,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
281290
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
282291
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
283292
</div>
284-
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && (
293+
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && !comparisonLoading && (
285294
<div className="flex items-center space-x-2">
286295
<div className={`w-2 h-2 rounded-full ${comparisonData.hasDifferences ? 'bg-orange-500' : 'bg-green-500'}`}></div>
287296
<span>Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'}</span>

src/app/_components/ScriptsGrid.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
215215
return null;
216216
}
217217

218+
// Create a unique key by combining slug, name, and index to handle duplicates
219+
const uniqueKey = `${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
220+
218221
return (
219222
<ScriptCard
220-
key={script.slug ?? `script-${index}`}
223+
key={uniqueKey}
221224
script={script}
222225
onClick={handleCardClick}
223226
/>

src/app/_components/TextViewer.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
2929
setError(null);
3030

3131
try {
32-
const [ctResponse, installResponse] = await Promise.allSettled([
32+
// Try to load from different possible locations
33+
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
3334
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
35+
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
36+
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
37+
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
3438
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
3539
]);
3640

@@ -43,6 +47,27 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
4347
}
4448
}
4549

50+
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
51+
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
52+
if (toolsData.result?.data?.json?.success) {
53+
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
54+
}
55+
}
56+
57+
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
58+
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
59+
if (vmData.result?.data?.json?.success) {
60+
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
61+
}
62+
}
63+
64+
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
65+
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
66+
if (vwData.result?.data?.json?.success) {
67+
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
68+
}
69+
}
70+
4671
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
4772
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
4873
if (installData.result?.data?.json?.success) {

src/app/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { useState } from 'react';
55
import { ScriptsGrid } from './_components/ScriptsGrid';
66
import { ResyncButton } from './_components/ResyncButton';
7+
import { RepoStatusButton } from './_components/RepoStatusButton';
78
import { Terminal } from './_components/Terminal';
89

910
export default function Home() {
@@ -30,6 +31,11 @@ export default function Home() {
3031
</p>
3132
</div>
3233

34+
{/* Repository Status and Update */}
35+
<div className="mb-8">
36+
<RepoStatusButton />
37+
</div>
38+
3339
{/* Resync Button */}
3440
<div className="mb-8">
3541
<div className="flex items-center justify-between mb-6">

src/env.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const env = createEnv({
1313
.default("development"),
1414
// Repository Configuration
1515
REPO_URL: z.string().url().optional(),
16+
ORIGINAL_REPO_URL: z.string().url().optional(),
1617
REPO_BRANCH: z.string().default("main"),
1718
SCRIPTS_DIRECTORY: z.string().default("scripts"),
1819
JSON_FOLDER: z.string().default("json"),
@@ -41,6 +42,7 @@ export const env = createEnv({
4142
NODE_ENV: process.env.NODE_ENV,
4243
// Repository Configuration
4344
REPO_URL: process.env.REPO_URL,
45+
ORIGINAL_REPO_URL: process.env.ORIGINAL_REPO_URL,
4446
REPO_BRANCH: process.env.REPO_BRANCH,
4547
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
4648
JSON_FOLDER: process.env.JSON_FOLDER,

src/server/api/routers/scripts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export const scriptsRouter = createTRPCRouter({
4141
return result;
4242
}),
4343

44+
// Full update repository (git pull, npm install, build)
45+
fullUpdateRepo: publicProcedure
46+
.mutation(async () => {
47+
const result = await gitManager.fullUpdate();
48+
return result;
49+
}),
50+
4451
// Get script content for viewing
4552
getScriptContent: publicProcedure
4653
.input(z.object({ path: z.string() }))

0 commit comments

Comments
 (0)