one red and one green, in the core apps
82 raw palette classes in FileBrowser, FileViewer, SystemMonitor, Chat and Plugins now name a state instead of a colour. `docs/design-language-interface.md` has said to do this for a long time and `tone.ts` implements it; eight files were following it. Only the families whose semantic token IS the colour already in use: destructive #ef4444 == text-red-500 exact success #16a34a == text-green-600 exact So `red-500` and `green-600` are byte-identical renames, and the neighbouring shades (`red-400`, `red-600`, `green-500`, `emerald-500`) shift by a little on purpose — having one red is the point. Verified on screen, not just typechecked. Three greys became `muted-foreground`, which is what "quieter than the text" means here. ── Amber is deliberately untouched, and needs a decision ── 38 amber classes remain and a regex must not have them. They are not warnings. Chat's own comment says it plainly: "the amber a RUNNING task chip wears". It is a BUSY tone, and the token set has no such thing — `--status-in-progress` exists and is blue. Mapping them to `warning` would change both meaning and colour: Duck's `--warning` is #fbbd23 (amber-400) while the code uses amber-600 #d97706 as text and amber-950 as ink on a fill. Text at that lightness on white is not readable, and "running" would start looking like "something is wrong". Two ways out, both the owner's call: add a sixth tone for busy/queued, or accept warning's colour and meaning for it. ── Deliberately out of scope ── The plugin candidates — Soulseek 308, Invoices 95, Wallet 68, Jellyfin 48, Photos 47, Transmission 41 — are two thirds of the debt and are leaving this repository. Their colours belong to their extraction, when the rules exist and their imports are being rewritten anyway. White and black are also untouched: they are media overlays in the file viewer and photo surfaces, correct in every theme because they sit on someone's photograph rather than on a themed surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,7 +139,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
|
||||
if (s.id === sessionId) setSessionId(null);
|
||||
void deleteSession(s.id);
|
||||
}}
|
||||
className="p-0.5 opacity-50 hover:opacity-100 text-red-500 cursor-pointer"
|
||||
className="p-0.5 opacity-50 hover:opacity-100 text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -131,7 +131,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
aria-label="Copy the code"
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -141,7 +141,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
|
||||
className={CONTROL}
|
||||
>
|
||||
{copied === 'md' ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400" />
|
||||
<Check className="h-3.5 w-3.5 text-success" />
|
||||
) : (
|
||||
// The label is what it produces. Backtick glyphs sit near cap height, so the nudge is what
|
||||
// optically centres them in the box — flex centring alone leaves them riding high.
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
|
||||
className={`p-1 rounded text-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-5 py-3 flex items-center gap-2 text-sm text-red-500">
|
||||
<div className="px-5 py-3 flex items-center gap-2 text-sm text-destructive">
|
||||
<CircleX className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
|
||||
<div className="flex items-center gap-2">
|
||||
{recording && <span className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />}
|
||||
{recording && <span className="h-3 w-3 rounded-full bg-destructive animate-pulse" />}
|
||||
<span className="text-sm text-duck-dark/60">{recording ? 'Recording...' : 'Starting...'}</span>
|
||||
</div>
|
||||
<div className="flex justify-end w-full gap-2">
|
||||
|
||||
+1
-1
@@ -413,7 +413,7 @@ const MenuItems = ({
|
||||
</Item>
|
||||
)}
|
||||
<Separator />
|
||||
<Item onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||
<Item onClick={() => onDelete(entry)} className="text-destructive cursor-pointer">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Item>
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn
|
||||
/>
|
||||
{/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you
|
||||
type past a colliding name. */}
|
||||
<p className="min-h-[1.25rem] text-xs text-red-600">{problem ?? ''}</p>
|
||||
<p className="min-h-[1.25rem] text-xs text-destructive">{problem ?? ''}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
+21
-23
@@ -240,18 +240,18 @@ const AgenticTaskRunner = ({
|
||||
{phase === 'running' ? (
|
||||
<button
|
||||
onClick={chat.stopGeneration}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</button>
|
||||
) : accRef.current.some((m) => m.role === 'error') ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Task failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-success font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
Task complete
|
||||
</span>
|
||||
@@ -544,9 +544,7 @@ const AudioMetaPanel = ({ meta, fileName }: { meta: AudioMeta; fileName: string
|
||||
{dur ? <span className="tabular-nums">{dur}</span> : null}
|
||||
<span
|
||||
className={`rounded-md px-1.5 py-0.5 font-medium ${
|
||||
meta.hasLyrics
|
||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-duck-dark/10 dark:bg-foreground/10'
|
||||
meta.hasLyrics ? 'bg-success/15 text-success dark:text-success' : 'bg-duck-dark/10 dark:bg-foreground/10'
|
||||
}`}
|
||||
>
|
||||
Lyrics: {meta.hasLyrics ? 'Yes' : 'No'}
|
||||
@@ -1268,7 +1266,7 @@ const ScriptRunner = ({
|
||||
<button
|
||||
onClick={() => submitJob('start')}
|
||||
disabled={jobDisabled}
|
||||
className={`${base} text-white ${jobRunning ? 'bg-red-500 hover:bg-red-500/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
|
||||
className={`${base} text-white ${jobRunning ? 'bg-destructive hover:bg-destructive/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
|
||||
>
|
||||
<Play className="h-4 w-4" />{' '}
|
||||
{canChooseRunMode ? (jobRunning ? 'Run now as job' : 'Run as job') : jobRunning ? 'Run now' : 'Run'}
|
||||
@@ -1290,9 +1288,9 @@ const ScriptRunner = ({
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
|
||||
title="Copy output"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-gray-200 rounded-lg p-4 pr-10">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-muted-foreground rounded-lg p-4 pr-10">
|
||||
<code>
|
||||
{runner.output.map((line, i) => (
|
||||
<span
|
||||
@@ -1302,7 +1300,7 @@ const ScriptRunner = ({
|
||||
// light mode.
|
||||
className={
|
||||
line.stream === 'stderr'
|
||||
? 'text-red-400'
|
||||
? 'text-destructive'
|
||||
: line.stream === 'system'
|
||||
? 'text-duck-teal/70'
|
||||
: undefined
|
||||
@@ -1320,18 +1318,18 @@ const ScriptRunner = ({
|
||||
{runner.phase === 'running' ? (
|
||||
<button
|
||||
onClick={runner.stop}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</button>
|
||||
) : runner.exitCode !== 0 ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Task failed (exit {runner.exitCode})
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-success font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
Task complete
|
||||
</span>
|
||||
@@ -1528,7 +1526,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
||||
{pipeline.currentStep.status === 'running' && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-success">done</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1541,12 +1539,12 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
||||
<span className="text-duck-dark/50 text-xs">
|
||||
{pDone}/{pTotal} done
|
||||
{pRunning > 0 && <span className="ml-1">· {pRunning} running</span>}
|
||||
{pError > 0 && <span className="ml-1 text-red-500">· {pError} failed</span>}
|
||||
{pError > 0 && <span className="ml-1 text-destructive">· {pError} failed</span>}
|
||||
</span>
|
||||
{pDone + pError < pTotal && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-success">done</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1577,10 +1575,10 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
||||
<span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />
|
||||
)}
|
||||
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
|
||||
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
|
||||
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
||||
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-success shrink-0" />}
|
||||
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-destructive shrink-0" />}
|
||||
<span
|
||||
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-destructive' : 'text-duck-dark/60'}`}
|
||||
>
|
||||
{it.label}
|
||||
</span>
|
||||
@@ -1590,7 +1588,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
||||
</span>
|
||||
)}
|
||||
{it.error && (
|
||||
<span className="ml-auto text-xs text-red-400 truncate max-w-[200px]" title={it.error}>
|
||||
<span className="ml-auto text-xs text-destructive truncate max-w-[200px]" title={it.error}>
|
||||
{it.error}
|
||||
</span>
|
||||
)}
|
||||
@@ -1618,18 +1616,18 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
|
||||
{pipeline.phase === 'running' ? (
|
||||
<button
|
||||
onClick={pipeline.stop}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop Pipeline
|
||||
</button>
|
||||
) : pipeline.hasError ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Pipeline failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<span className="flex items-center gap-2 text-sm text-success font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
Pipeline complete
|
||||
</span>
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps)
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
|
||||
className="p-1.5 rounded-md text-destructive hover:bg-destructive cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
+3
-3
@@ -249,7 +249,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-red-500/70" />
|
||||
<AlertCircle size={16} className="text-destructive/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -265,7 +265,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
|
||||
<p className="truncate text-sm font-medium text-destructive">Could not fetch</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -276,7 +276,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const FileViewerBody = () => {
|
||||
// action that still works, and it is offered rather than left to be guessed at.
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 px-6 text-center">
|
||||
<FileWarning className="h-8 w-8 text-duck-dark/30" />
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
|
||||
|
||||
@@ -50,7 +50,7 @@ export const FileViewerHeader = () => {
|
||||
) : fileType === 'image' ? (
|
||||
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
|
||||
) : fileType === 'pdf' ? (
|
||||
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
|
||||
<FileType2 className="h-4 w-4 text-destructive shrink-0" />
|
||||
) : fileType === 'archive' ? (
|
||||
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
|
||||
) : (
|
||||
|
||||
@@ -80,7 +80,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load audio file</span>
|
||||
<span className="text-sm text-destructive">Failed to load audio file</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,10 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={toggleMute} className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
||||
>
|
||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
|
||||
@@ -78,7 +78,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load image</span>
|
||||
<span className="text-sm text-destructive">Failed to load image</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-red-500">Failed to load video</span>
|
||||
<span className="text-sm text-destructive">Failed to load video</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,9 +227,22 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
||||
if (playing) setShowControls(false);
|
||||
}}
|
||||
>
|
||||
<video ref={videoRef} src={videoSrc} preload="metadata" playsInline className="w-full h-full object-contain" onClick={togglePlay}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{subtitles.map((t) => (
|
||||
<track key={t.id} kind="subtitles" src={getSubtitleVttUrl(filePath, t.id, root)} srcLang={t.lang} label={t.label} />
|
||||
<track
|
||||
key={t.id}
|
||||
kind="subtitles"
|
||||
src={getSubtitleVttUrl(filePath, t.id, root)}
|
||||
srcLang={t.lang}
|
||||
label={t.label}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
|
||||
@@ -294,7 +307,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={toggleMute} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
@@ -348,7 +364,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={toggleFullscreen} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ const Button = ({
|
||||
const tones = {
|
||||
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
|
||||
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
|
||||
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
|
||||
danger: 'border border-destructive text-destructive hover:bg-destructive',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
@@ -86,7 +86,7 @@ const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
|
||||
<ul className="mt-2 space-y-2">
|
||||
{deps.items.map((item) => (
|
||||
<li key={item.binary} className="flex gap-2 text-sm">
|
||||
<span aria-hidden className={item.present ? 'text-emerald-600' : 'text-amber-600'}>
|
||||
<span aria-hidden className={item.present ? 'text-success' : 'text-amber-600'}>
|
||||
{item.present ? '✓' : '•'}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
@@ -152,7 +152,7 @@ const ActionLog = ({
|
||||
{running}…
|
||||
</>
|
||||
) : (
|
||||
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
|
||||
<span className={result?.ok ? 'text-success' : 'text-destructive'}>{result?.ok ? 'Done' : 'Failed'}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Raw output is dimmer and indented under the step that produced it — present when you want it,
|
||||
@@ -165,7 +165,7 @@ const ActionLog = ({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
|
||||
{result?.error ? <div className="mt-2 text-xs text-destructive">{result.error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -309,7 +309,7 @@ const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; o
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirming(true)}
|
||||
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-red-600 disabled:opacity-40"
|
||||
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
Delete {plugin.label} and all its data
|
||||
</button>
|
||||
@@ -318,7 +318,7 @@ const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; o
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-lg border border-red-500/30 bg-red-500/5 p-4">
|
||||
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<p className="text-sm font-medium text-duck-dark">Delete {plugin.label} completely?</p>
|
||||
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
|
||||
<li>
|
||||
|
||||
@@ -13,7 +13,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
|
||||
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
|
||||
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
|
||||
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
|
||||
return <span className="text-xs text-emerald-600">enabled</span>;
|
||||
return <span className="text-xs text-success">enabled</span>;
|
||||
};
|
||||
|
||||
export const PluginsList = () => {
|
||||
@@ -97,10 +97,10 @@ export const PluginsList = () => {
|
||||
manifest looks exactly like a plugin nobody wrote. */}
|
||||
{broken.map((b) => (
|
||||
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
|
||||
<div className="text-xs text-red-600">{b.error}</div>
|
||||
<div className="text-xs text-destructive">{b.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -10,29 +10,53 @@ type Stats = {
|
||||
uptimeSec: number;
|
||||
loadavg: number[];
|
||||
cpu: { model: string; cores: number; usagePct: number; perCore: number[] };
|
||||
mem: { totalBytes: number; usedBytes: number; freeBytes: number; usedPct: number; swapTotalBytes: number; swapUsedBytes: number } | null;
|
||||
mem: {
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
freeBytes: number;
|
||||
usedPct: number;
|
||||
swapTotalBytes: number;
|
||||
swapUsedBytes: number;
|
||||
} | null;
|
||||
disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[];
|
||||
processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[];
|
||||
temp: { cpuC: number | null; cpuLabel: string | null; sensors: Sensor[] } | null;
|
||||
gpu: { busyPct: number; vramUsedBytes: number; vramTotalBytes: number } | null;
|
||||
net: { rxBytesPerSec: number; txBytesPerSec: number; interfaces: { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] } | null;
|
||||
net: {
|
||||
rxBytesPerSec: number;
|
||||
txBytesPerSec: number;
|
||||
interfaces: { name: string; rxBytesPerSec: number; txBytesPerSec: number }[];
|
||||
} | null;
|
||||
power: { cpuWatts: number | null; gpuWatts: number | null } | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
const barColor = (pct: number) => (pct >= 85 ? 'bg-red-500' : pct >= 60 ? 'bg-amber-500' : 'bg-emerald-500');
|
||||
const tempColor = (c: number) => (c >= 80 ? 'text-red-500' : c >= 65 ? 'text-amber-500' : 'text-emerald-500');
|
||||
const barColor = (pct: number) => (pct >= 85 ? 'bg-destructive' : pct >= 60 ? 'bg-amber-500' : 'bg-success');
|
||||
const tempColor = (c: number) => (c >= 80 ? 'text-destructive' : c >= 65 ? 'text-amber-500' : 'text-success');
|
||||
const fmtRate = (bps: number) => `${fmtBytes(bps)}/s`;
|
||||
|
||||
const Bar = ({ pct }: { pct: number }) => (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className={`h-full rounded-full transition-all ${barColor(pct)}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor(pct)}`}
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Card = ({ title, icon: Icon, children, className }: { title: string; icon: typeof Cpu; children: React.ReactNode; className?: string }) => (
|
||||
const Card = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
icon: typeof Cpu;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`flex flex-col gap-3 rounded-xl border border-border bg-card p-4 ${className ?? ''}`}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Icon size={16} className="text-primary" />
|
||||
@@ -77,12 +101,16 @@ export const BtopView = () => {
|
||||
<div className="flex items-center gap-4">
|
||||
<span>up {fmtDuration(stats.uptimeSec * 1000)}</span>
|
||||
<span>load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')}</span>
|
||||
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
|
||||
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-success" title="live" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
@@ -93,7 +121,9 @@ export const BtopView = () => {
|
||||
<span className="text-xs text-muted-foreground">{stats.cpu.cores} cores</span>
|
||||
</div>
|
||||
<Bar pct={stats.cpu.usagePct} />
|
||||
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>{stats.cpu.model}</p>
|
||||
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>
|
||||
{stats.cpu.model}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-1 pt-1">
|
||||
{stats.cpu.perCore.map((c, i) => (
|
||||
<div key={i} className="flex h-10 items-end rounded bg-muted" title={`core ${i}: ${c}%`}>
|
||||
@@ -130,8 +160,12 @@ export const BtopView = () => {
|
||||
{stats.disks.map((d) => (
|
||||
<div key={d.mount} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>{d.mount}</span>
|
||||
<span className="text-muted-foreground">{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}</span>
|
||||
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>
|
||||
{d.mount}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Bar pct={d.usedPct} />
|
||||
</div>
|
||||
@@ -147,10 +181,14 @@ export const BtopView = () => {
|
||||
<>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-semibold text-foreground">{stats.gpu.busyPct}%</span>
|
||||
<span className="text-xs text-muted-foreground">VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Bar pct={stats.gpu.busyPct} />
|
||||
{stats.gpu.vramTotalBytes > 0 && <Bar pct={(stats.gpu.vramUsedBytes / stats.gpu.vramTotalBytes) * 100} />}
|
||||
{stats.gpu.vramTotalBytes > 0 && (
|
||||
<Bar pct={(stats.gpu.vramUsedBytes / stats.gpu.vramTotalBytes) * 100} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">no gpu</p>
|
||||
@@ -161,7 +199,9 @@ export const BtopView = () => {
|
||||
{stats.temp?.cpuC != null ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className={`text-3xl font-semibold ${tempColor(stats.temp.cpuC)}`}>{stats.temp.cpuC.toFixed(0)}°C</span>
|
||||
<span className={`text-3xl font-semibold ${tempColor(stats.temp.cpuC)}`}>
|
||||
{stats.temp.cpuC.toFixed(0)}°C
|
||||
</span>
|
||||
<span className="truncate pl-2 text-xs text-muted-foreground">{stats.temp.cpuLabel}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
@@ -170,7 +210,9 @@ export const BtopView = () => {
|
||||
.slice(0, 6)
|
||||
.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate text-muted-foreground">{s.name} · {s.label}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{s.name} · {s.label}
|
||||
</span>
|
||||
<span className={`font-mono ${tempColor(s.celsius)}`}>{s.celsius.toFixed(0)}°C</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -192,7 +234,9 @@ export const BtopView = () => {
|
||||
{stats.net.interfaces.slice(0, 4).map((i) => (
|
||||
<div key={i.name} className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate font-mono">{i.name}</span>
|
||||
<span className="font-mono">↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)}</span>
|
||||
<span className="font-mono">
|
||||
↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{stats.net.interfaces.length === 0 && <span className="text-xs text-muted-foreground">idle</span>}
|
||||
@@ -208,11 +252,15 @@ export const BtopView = () => {
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">CPU package</span>
|
||||
<span className="font-mono text-foreground">{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">GPU</span>
|
||||
<span className="font-mono text-foreground">{stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'}</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -239,8 +287,12 @@ export const BtopView = () => {
|
||||
<td className="py-1.5 pr-4 font-mono text-muted-foreground">{p.pid}</td>
|
||||
<td className="py-1.5 pr-4 text-muted-foreground">{p.user}</td>
|
||||
<td className="py-1.5 pr-4 text-right font-mono text-foreground">{p.cpuPct.toFixed(1)}</td>
|
||||
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">{p.memPct.toFixed(1)}</td>
|
||||
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>{p.command}</td>
|
||||
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">
|
||||
{p.memPct.toFixed(1)}
|
||||
</td>
|
||||
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>
|
||||
{p.command}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LogStream } from './LogStream';
|
||||
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const stateColor = (s: string) => (s === 'running' ? 'bg-emerald-500' : s === 'exited' ? 'bg-red-500' : 'bg-amber-500');
|
||||
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-amber-500');
|
||||
|
||||
export const DockerView = () => {
|
||||
const { get } = useClient();
|
||||
@@ -45,7 +45,9 @@ export const DockerView = () => {
|
||||
);
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
const filtered = (containers ?? []).filter((c) => !q || c.name.toLowerCase().includes(q) || c.image.toLowerCase().includes(q));
|
||||
const filtered = (containers ?? []).filter(
|
||||
(c) => !q || c.name.toLowerCase().includes(q) || c.image.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
||||
@@ -61,7 +63,11 @@ export const DockerView = () => {
|
||||
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((c) => (
|
||||
<button
|
||||
@@ -74,9 +80,17 @@ export const DockerView = () => {
|
||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${stateColor(c.state)}`} />
|
||||
<span className="truncate text-sm font-medium text-foreground">{c.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground" title={c.image}>{c.image}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground/70" title={c.status}>{c.status}</div>
|
||||
{c.ports && <div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/60" title={c.ports}>{c.ports}</div>}
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground" title={c.image}>
|
||||
{c.image}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-muted-foreground/70" title={c.status}>
|
||||
{c.status}
|
||||
</div>
|
||||
{c.ports && (
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/60" title={c.ports}>
|
||||
{c.ports}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,17 @@ const MAX_LINES = 1200;
|
||||
|
||||
// Generic live log viewer: SSE-tails `${streamUrl}&token=…` (server sends one `data:` frame per line),
|
||||
// on a solid-black terminal-style pane. Used for both pm2 and docker logs.
|
||||
export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl: string; title: string; backLabel: string; onBack: () => void }) => {
|
||||
export const LogStream = ({
|
||||
streamUrl,
|
||||
title,
|
||||
backLabel,
|
||||
onBack,
|
||||
}: {
|
||||
streamUrl: string;
|
||||
title: string;
|
||||
backLabel: string;
|
||||
onBack: () => void;
|
||||
}) => {
|
||||
const { token } = useClient();
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const scrollRef = useRef<HTMLPreElement | null>(null);
|
||||
@@ -25,14 +35,21 @@ export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl:
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border p-3">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={16} /> {backLabel}
|
||||
</button>
|
||||
<span className="truncate font-mono text-sm text-foreground">{title}</span>
|
||||
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
|
||||
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-success" title="live" />
|
||||
</div>
|
||||
<pre ref={scrollRef} className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-black p-3 font-mono text-xs text-neutral-200">
|
||||
{lines.length === 0 ? <span className="text-neutral-500">connecting…</span> : lines.join('\n')}
|
||||
<pre
|
||||
ref={scrollRef}
|
||||
className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-black p-3 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{lines.length === 0 ? <span className="text-muted-foreground">connecting…</span> : lines.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,27 @@ import { useClient } from 'hooks/useClient';
|
||||
import { fmtBytes, fmtDuration } from './shared';
|
||||
import { LogStream } from './LogStream';
|
||||
|
||||
type Proc = { id: number; name: string; status: string; pid: number | null; cpuPct: number; memBytes: number; restarts: number; uptimeMs: number };
|
||||
type Proc = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
cpuPct: number;
|
||||
memBytes: number;
|
||||
restarts: number;
|
||||
uptimeMs: number;
|
||||
};
|
||||
type SortKey = 'id' | 'name' | 'status' | 'cpuPct' | 'memBytes' | 'restarts' | 'uptimeMs' | 'pid';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const statusColor = (s: string) =>
|
||||
s === 'online' ? 'bg-emerald-500' : s === 'stopped' ? 'bg-muted-foreground/40' : s === 'errored' ? 'bg-red-500' : 'bg-amber-500';
|
||||
s === 'online'
|
||||
? 'bg-success'
|
||||
: s === 'stopped'
|
||||
? 'bg-muted-foreground/40'
|
||||
: s === 'errored'
|
||||
? 'bg-destructive'
|
||||
: 'bg-amber-500';
|
||||
|
||||
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
|
||||
{ key: 'id', label: '#', align: 'right' },
|
||||
@@ -62,7 +77,8 @@ export const Pm2View = () => {
|
||||
arr.sort((a, b) => {
|
||||
const av = a[sortKey] ?? 0;
|
||||
const bv = b[sortKey] ?? 0;
|
||||
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : (av as number) - (bv as number);
|
||||
const cmp =
|
||||
typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : (av as number) - (bv as number);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return arr;
|
||||
@@ -81,8 +97,14 @@ export const Pm2View = () => {
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-3 text-xs text-muted-foreground">pm2 · {procs?.length ?? 0} processes · click a name for live logs</div>
|
||||
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
pm2 · {procs?.length ?? 0} processes · click a name for live logs
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
@@ -95,7 +117,9 @@ export const Pm2View = () => {
|
||||
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="w-2 text-[10px]">{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}</span>
|
||||
<span className="w-2 text-[10px]">
|
||||
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
@@ -129,7 +153,9 @@ export const Pm2View = () => {
|
||||
))}
|
||||
{procs && procs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="p-3 text-muted-foreground">no processes</td>
|
||||
<td colSpan={8} className="p-3 text-muted-foreground">
|
||||
no processes
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user