Skip to content

Commit 6d39490

Browse files
authored
Dashboard plugin update panel (#77)
1 parent aa81f5d commit 6d39490

File tree

6 files changed

+992
-3
lines changed

6 files changed

+992
-3
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import React from 'react';
2+
import {
3+
Alert,
4+
Box,
5+
Button,
6+
Card,
7+
CardContent,
8+
Chip,
9+
CircularProgress,
10+
Divider,
11+
IconButton,
12+
LinearProgress,
13+
Link,
14+
Skeleton,
15+
Stack,
16+
Tooltip,
17+
Typography,
18+
} from '@mui/material';
19+
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
20+
import RefreshIcon from '@mui/icons-material/Refresh';
21+
import ScheduleIcon from '@mui/icons-material/Schedule';
22+
import CloseIcon from '@mui/icons-material/Close';
23+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
24+
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
25+
import {
26+
PluginUpdateBatchProgress,
27+
PluginUpdateFeedItem,
28+
PluginUpdateFeedStatus,
29+
} from '../hooks/usePluginUpdateFeed';
30+
31+
interface PluginUpdatesPanelProps {
32+
updates: PluginUpdateFeedItem[];
33+
status: PluginUpdateFeedStatus;
34+
error: string | null;
35+
lastChecked: string | null;
36+
isUpdatingAll: boolean;
37+
batchProgress: PluginUpdateBatchProgress;
38+
onUpdate: (pluginId: string) => void | Promise<void>;
39+
onUpdateAll: () => void | Promise<void>;
40+
onRefresh: () => void | Promise<void>;
41+
onDismiss: (pluginId: string) => void;
42+
onRetry: () => void | Promise<void>;
43+
}
44+
45+
const formatTimestamp = (timestamp: string | null): string => {
46+
if (!timestamp) {
47+
return 'Never checked';
48+
}
49+
50+
const date = new Date(timestamp);
51+
if (Number.isNaN(date.getTime())) {
52+
return 'Last check unknown';
53+
}
54+
55+
return `Last checked ${date.toLocaleString()}`;
56+
};
57+
58+
const formatPluginLabel = (plugin: PluginUpdateFeedItem): string => {
59+
if (plugin.pluginName && plugin.pluginName.trim()) {
60+
return plugin.pluginName;
61+
}
62+
63+
const slug = plugin.pluginId.split('_').slice(1).join('_') || plugin.pluginId;
64+
const spaced = slug
65+
.replace(/[_-]+/g, ' ')
66+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
67+
.replace(/\s+/g, ' ')
68+
.trim();
69+
70+
if (!spaced) {
71+
return plugin.pluginId;
72+
}
73+
74+
return spaced
75+
.split(' ')
76+
.map(part => (part ? part[0].toUpperCase() + part.slice(1) : part))
77+
.join(' ');
78+
};
79+
80+
const PluginUpdatesPanel: React.FC<PluginUpdatesPanelProps> = ({
81+
updates,
82+
status,
83+
error,
84+
lastChecked,
85+
isUpdatingAll,
86+
batchProgress,
87+
onUpdate,
88+
onUpdateAll,
89+
onRefresh,
90+
onDismiss,
91+
onRetry,
92+
}) => {
93+
const showSkeleton = status === 'loading' && updates.length === 0 && !lastChecked;
94+
const showEmpty = status === 'empty';
95+
const showError = status === 'error';
96+
97+
const lastCheckedLabel = formatTimestamp(lastChecked);
98+
const progressValue = batchProgress.total > 0 && batchProgress.processed <= batchProgress.total
99+
? Math.round((batchProgress.processed / batchProgress.total) * 100)
100+
: 0;
101+
102+
const renderHeaderActions = () => (
103+
<Stack direction="row" spacing={1} alignItems="center">
104+
<Tooltip title="Refresh now">
105+
<span>
106+
<IconButton
107+
color="primary"
108+
onClick={() => onRefresh()}
109+
disabled={status === 'loading'}
110+
size="small"
111+
>
112+
<RefreshIcon fontSize="small" />
113+
</IconButton>
114+
</span>
115+
</Tooltip>
116+
<Button
117+
variant="contained"
118+
onClick={() => onUpdateAll()}
119+
disabled={updates.length === 0 || isUpdatingAll}
120+
startIcon={
121+
isUpdatingAll ? <CircularProgress size={16} color="inherit" /> : <SystemUpdateAltIcon fontSize="small" />
122+
}
123+
>
124+
{isUpdatingAll ? 'Updating...' : 'Update All'}
125+
</Button>
126+
</Stack>
127+
);
128+
129+
const renderProgress = () => {
130+
if (!isUpdatingAll && batchProgress.processed === 0) {
131+
return null;
132+
}
133+
134+
return (
135+
<Box mt={2}>
136+
<LinearProgress
137+
variant={batchProgress.total > 0 ? 'determinate' : 'indeterminate'}
138+
value={batchProgress.total > 0 ? progressValue : undefined}
139+
/>
140+
{batchProgress.total > 0 && (
141+
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
142+
{`Processed ${batchProgress.processed} of ${batchProgress.total} (Success: ${batchProgress.succeeded}, Failed: ${batchProgress.failed})`}
143+
</Typography>
144+
)}
145+
</Box>
146+
);
147+
};
148+
149+
const renderError = () => {
150+
if (!showError) {
151+
return null;
152+
}
153+
154+
return (
155+
<Box mt={2}>
156+
<Alert
157+
severity="error"
158+
icon={<ErrorOutlineIcon fontSize="small" />}
159+
action={
160+
<Button color="inherit" size="small" onClick={() => onRetry()}>
161+
Retry
162+
</Button>
163+
}
164+
>
165+
{error || 'We hit a snag while checking for plugin updates.'}
166+
</Alert>
167+
</Box>
168+
);
169+
};
170+
171+
const renderEmpty = () => {
172+
if (!showEmpty) {
173+
return null;
174+
}
175+
176+
return (
177+
<Box mt={2}>
178+
<Alert severity="info" icon={<InfoOutlinedIcon fontSize="small" />}>No plugins need updates right now.</Alert>
179+
</Box>
180+
);
181+
};
182+
183+
const renderSkeleton = () => {
184+
if (!showSkeleton) {
185+
return null;
186+
}
187+
188+
return (
189+
<Stack spacing={2} mt={2}>
190+
{Array.from({ length: 3 }).map((_, index) => (
191+
<Box key={`plugin-update-skeleton-${index}`}>
192+
<Skeleton variant="text" width="30%" height={28} />
193+
<Skeleton variant="text" width="45%" height={24} />
194+
<Skeleton variant="rectangular" width="100%" height={40} sx={{ mt: 1 }} />
195+
</Box>
196+
))}
197+
</Stack>
198+
);
199+
};
200+
201+
const renderUpdates = () => {
202+
if (showSkeleton || showEmpty) {
203+
return null;
204+
}
205+
206+
return (
207+
<Stack
208+
mt={2}
209+
spacing={2}
210+
divider={<Divider flexItem sx={{ borderColor: 'divider', opacity: 0.5 }} />}
211+
>
212+
{updates.map(update => {
213+
const isProcessing = update.status === 'updating';
214+
215+
return (
216+
<Box
217+
key={update.pluginId}
218+
sx={{
219+
display: 'flex',
220+
flexDirection: { xs: 'column', md: 'row' },
221+
alignItems: { xs: 'flex-start', md: 'center' },
222+
justifyContent: 'space-between',
223+
gap: 2,
224+
}}
225+
>
226+
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
227+
<Typography variant="subtitle1" sx={{ wordBreak: 'break-word' }}>
228+
{formatPluginLabel(update)}
229+
</Typography>
230+
<Stack direction="row" spacing={1} alignItems="center" mt={0.5} flexWrap="wrap">
231+
<Chip size="small" label={`Current ${update.currentVersion || 'n/a'}`} variant="outlined" />
232+
<Chip size="small" label={`Latest ${update.latestVersion || 'n/a'}`} color="primary" />
233+
<Stack direction="row" spacing={0.5} alignItems="center">
234+
<ScheduleIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
235+
<Typography variant="caption" color="text.secondary">
236+
{update.lastChecked ? new Date(update.lastChecked).toLocaleString() : 'Not yet checked'}
237+
</Typography>
238+
</Stack>
239+
</Stack>
240+
{update.repoUrl && (
241+
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
242+
<Link href={update.repoUrl} target="_blank" rel="noopener">
243+
{update.repoUrl}
244+
</Link>
245+
</Typography>
246+
)}
247+
{update.status === 'error' && update.error && (
248+
<Alert severity="warning" sx={{ mt: 1 }}>
249+
{update.error}
250+
</Alert>
251+
)}
252+
</Box>
253+
<Stack direction="row" spacing={1} alignItems="center">
254+
<Button
255+
variant="contained"
256+
color="primary"
257+
onClick={() => onUpdate(update.pluginId)}
258+
disabled={isProcessing || isUpdatingAll}
259+
startIcon={
260+
isProcessing ? <CircularProgress size={16} color="inherit" /> : <SystemUpdateAltIcon fontSize="small" />
261+
}
262+
>
263+
{isProcessing ? 'Updating...' : 'Update'}
264+
</Button>
265+
<Button
266+
variant="text"
267+
color="inherit"
268+
onClick={() => onDismiss(update.pluginId)}
269+
startIcon={<CloseIcon fontSize="small" />}
270+
>
271+
Later
272+
</Button>
273+
</Stack>
274+
</Box>
275+
);
276+
})}
277+
</Stack>
278+
);
279+
};
280+
281+
return (
282+
<Card>
283+
<CardContent sx={{ p: 3 }}>
284+
<Stack
285+
direction={{ xs: 'column', sm: 'row' }}
286+
spacing={2}
287+
alignItems={{ xs: 'flex-start', sm: 'center' }}
288+
justifyContent="space-between"
289+
>
290+
<Box>
291+
<Typography variant="h6">Plugin updates</Typography>
292+
<Stack direction="row" spacing={0.75} alignItems="center" mt={0.5}>
293+
<ScheduleIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
294+
<Typography variant="caption" color="text.secondary">
295+
{lastCheckedLabel}
296+
</Typography>
297+
</Stack>
298+
</Box>
299+
{renderHeaderActions()}
300+
</Stack>
301+
302+
{renderProgress()}
303+
{renderError()}
304+
{renderSkeleton()}
305+
{renderEmpty()}
306+
{renderUpdates()}
307+
</CardContent>
308+
</Card>
309+
);
310+
};
311+
312+
export default PluginUpdatesPanel;
313+
314+
315+

0 commit comments

Comments
 (0)