Merge branch 'claude/youthful-cerf-ibvxfb': in-app shell prompt picker, Help page scroll fix, Glance/System Status tweaks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk # Conflicts: # src/pages/Help.tsx
This commit is contained in:
commit
1da719c541
11 changed files with 443 additions and 7 deletions
31
backend/src/routes/shellPrompt.ts
Normal file
31
backend/src/routes/shellPrompt.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { withSshClient } from '../ssh/docker.js'
|
||||||
|
import { PROMPT_PRESETS, checkPromptStatus, installPrompt, isValidPresetId } from '../ssh/shellPrompt.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell prompt setup for SSH-managed hosts: lets the Terminal page show a
|
||||||
|
* preset picker and install Starship + a Nerd Font on demand, instead of
|
||||||
|
* requiring the user to run a script by hand on every host.
|
||||||
|
*/
|
||||||
|
export async function shellPromptRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('onRequest', app.authenticate)
|
||||||
|
|
||||||
|
app.get('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
|
||||||
|
const integrationId = Number((req.params as { integrationId: string }).integrationId)
|
||||||
|
const result = await withSshClient(integrationId, (client) => checkPromptStatus(client))
|
||||||
|
if (!result.ok) return reply.code(502).send({ error: result.error })
|
||||||
|
return { presets: PROMPT_PRESETS, status: result.value }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ssh/:integrationId/shell-prompt', async (req, reply) => {
|
||||||
|
const integrationId = Number((req.params as { integrationId: string }).integrationId)
|
||||||
|
const parsed = z.object({ presetId: z.string() }).safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success || !isValidPresetId(parsed.data.presetId)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid preset' })
|
||||||
|
}
|
||||||
|
const result = await withSshClient(integrationId, (client) => installPrompt(client, parsed.data.presetId))
|
||||||
|
if (!result.ok) return reply.code(502).send({ error: result.error })
|
||||||
|
return { ok: true, log: result.value.log }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js'
|
||||||
import { fileRoutes } from './routes/files.js'
|
import { fileRoutes } from './routes/files.js'
|
||||||
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
|
import { dockerRoutes, dockerExecRoutes } from './routes/docker.js'
|
||||||
import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js'
|
import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js'
|
||||||
|
import { shellPromptRoutes } from './routes/shellPrompt.js'
|
||||||
import { agentIngestRoutes, agentRoutes } from './routes/agents.js'
|
import { agentIngestRoutes, agentRoutes } from './routes/agents.js'
|
||||||
import { guacamoleRoutes } from './routes/guacamole.js'
|
import { guacamoleRoutes } from './routes/guacamole.js'
|
||||||
import { metricsRoutes } from './routes/metrics.js'
|
import { metricsRoutes } from './routes/metrics.js'
|
||||||
|
|
@ -94,6 +95,7 @@ await app.register(dockerRoutes)
|
||||||
await app.register(dockerExecRoutes)
|
await app.register(dockerExecRoutes)
|
||||||
await app.register(dockerSshRoutes)
|
await app.register(dockerSshRoutes)
|
||||||
await app.register(dockerSshExecRoutes)
|
await app.register(dockerSshExecRoutes)
|
||||||
|
await app.register(shellPromptRoutes)
|
||||||
await app.register(agentIngestRoutes)
|
await app.register(agentIngestRoutes)
|
||||||
await app.register(agentRoutes)
|
await app.register(agentRoutes)
|
||||||
await app.register(guacamoleRoutes)
|
await app.register(guacamoleRoutes)
|
||||||
|
|
|
||||||
131
backend/src/ssh/shellPrompt.ts
Normal file
131
backend/src/ssh/shellPrompt.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { Client } from 'ssh2'
|
||||||
|
import { execCommand } from './metrics/common.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs/configures a Starship shell prompt + the Nerd Font icons it needs
|
||||||
|
* on a remote SSH host, on demand from the Terminal page. Idempotent: each
|
||||||
|
* step checks first and skips work that's already done, so re-running (e.g.
|
||||||
|
* switching presets) doesn't re-download anything unnecessary.
|
||||||
|
*/
|
||||||
|
export interface PromptPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
/** Name passed to `starship preset <name>`. */
|
||||||
|
starshipPreset: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROMPT_PRESETS: PromptPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'nerd-font-symbols',
|
||||||
|
name: 'Nerd Font Symbols',
|
||||||
|
description: 'Classic Starship look — OS icon, git branch/status, language runtime icons.',
|
||||||
|
starshipPreset: 'nerd-font-symbols',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pastel-powerline',
|
||||||
|
name: 'Pastel Powerline',
|
||||||
|
description: 'Powerline-style segments with soft pastel colors and icons.',
|
||||||
|
starshipPreset: 'pastel-powerline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tokyo-night',
|
||||||
|
name: 'Tokyo Night',
|
||||||
|
description: 'Tokyo Night colorway with a clean icon-led layout.',
|
||||||
|
starshipPreset: 'tokyo-night',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isValidPresetId(id: string): boolean {
|
||||||
|
return PROMPT_PRESETS.some((p) => p.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NERD_FONT_VERSION = 'v3.4.0'
|
||||||
|
const NERD_FONT_NAME = 'JetBrainsMono'
|
||||||
|
const NERD_FONT_DIR = '.local/share/fonts/ArchNestNerdFont'
|
||||||
|
|
||||||
|
export interface PromptStatus {
|
||||||
|
starshipInstalled: boolean
|
||||||
|
starshipVersion: string | null
|
||||||
|
fontInstalled: boolean
|
||||||
|
configuredPreset: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPromptStatus(client: Client): Promise<PromptStatus> {
|
||||||
|
const { stdout: versionOut, code: versionCode } = await execCommand(
|
||||||
|
client,
|
||||||
|
'starship --version 2>/dev/null',
|
||||||
|
)
|
||||||
|
const starshipInstalled = versionCode === 0
|
||||||
|
const starshipVersion = starshipInstalled ? versionOut.split('\n')[0]?.trim() || null : null
|
||||||
|
|
||||||
|
const { code: fontCode } = await execCommand(
|
||||||
|
client,
|
||||||
|
`test -d "\${HOME}/${NERD_FONT_DIR}" && [ -n "$(ls -A "\${HOME}/${NERD_FONT_DIR}" 2>/dev/null)" ]`,
|
||||||
|
)
|
||||||
|
const fontInstalled = fontCode === 0
|
||||||
|
|
||||||
|
const { stdout: presetOut, code: presetCode } = await execCommand(
|
||||||
|
client,
|
||||||
|
'grep -o "ArchNest preset: [a-z-]*" "${HOME}/.config/starship.toml" 2>/dev/null | head -1',
|
||||||
|
)
|
||||||
|
const configuredPreset = presetCode === 0 ? presetOut.replace('ArchNest preset:', '').trim() || null : null
|
||||||
|
|
||||||
|
return { starshipInstalled, starshipVersion, fontInstalled, configuredPreset }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently installs Starship + the Nerd Font (if either is missing), writes
|
||||||
|
* a starship.toml for the chosen preset, and wires the init line into
|
||||||
|
* .bashrc/.zshrc if either rc file exists and doesn't already have it.
|
||||||
|
*/
|
||||||
|
export async function installPrompt(client: Client, presetId: string): Promise<{ log: string }> {
|
||||||
|
const preset = PROMPT_PRESETS.find((p) => p.id === presetId)
|
||||||
|
if (!preset) throw new Error('Unknown preset')
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
set -e
|
||||||
|
LOG() { printf '%s\\n' "$1"; }
|
||||||
|
|
||||||
|
if command -v starship >/dev/null 2>&1; then
|
||||||
|
LOG "starship already installed, skipping install"
|
||||||
|
else
|
||||||
|
LOG "installing starship..."
|
||||||
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FONT_DIR="\${HOME}/${NERD_FONT_DIR}"
|
||||||
|
if [ -d "$FONT_DIR" ] && [ -n "$(ls -A "$FONT_DIR" 2>/dev/null)" ]; then
|
||||||
|
LOG "nerd font already installed, skipping download"
|
||||||
|
else
|
||||||
|
LOG "installing ${NERD_FONT_NAME} Nerd Font..."
|
||||||
|
mkdir -p "$FONT_DIR"
|
||||||
|
TMP_TAR="$(mktemp)"
|
||||||
|
curl -fsSL -o "$TMP_TAR" "https://github.com/ryanoasis/nerd-fonts/releases/download/${NERD_FONT_VERSION}/${NERD_FONT_NAME}.tar.xz"
|
||||||
|
tar -xJf "$TMP_TAR" -C "$FONT_DIR"
|
||||||
|
rm -f "$TMP_TAR"
|
||||||
|
command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$FONT_DIR" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOG "writing starship config for preset: ${preset.starshipPreset}"
|
||||||
|
mkdir -p "\${HOME}/.config"
|
||||||
|
starship preset ${preset.starshipPreset} -o "\${HOME}/.config/starship.toml"
|
||||||
|
printf '\\n# ArchNest preset: ${preset.id}\\n' >> "\${HOME}/.config/starship.toml"
|
||||||
|
|
||||||
|
add_init_line() {
|
||||||
|
local rcfile="$1" line="$2"
|
||||||
|
[ -f "$rcfile" ] || return 0
|
||||||
|
grep -qF "$line" "$rcfile" || printf '\\n# Added by ArchNest\\n%s\\n' "$line" >> "$rcfile"
|
||||||
|
}
|
||||||
|
add_init_line "\${HOME}/.bashrc" 'eval "$(starship init bash)"'
|
||||||
|
add_init_line "\${HOME}/.zshrc" 'eval "$(starship init zsh)"'
|
||||||
|
|
||||||
|
LOG "done"
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
const { stdout, stderr, code } = await execCommand(client, script, 180000)
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(stderr.trim() || stdout.trim() || `Install script exited with code ${code}`)
|
||||||
|
}
|
||||||
|
return { log: stdout.trim() }
|
||||||
|
}
|
||||||
73
scripts/install-starship-nerdfont.sh
Executable file
73
scripts/install-starship-nerdfont.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs Starship (the prompt) + JetBrainsMono Nerd Font (the icons) on a
|
||||||
|
# host you SSH into from ArchNest's Terminal page, and wires Starship into
|
||||||
|
# bash/zsh if either is present. Safe to re-run.
|
||||||
|
#
|
||||||
|
# This repo is private, so there's no public raw-URL one-liner. Run it directly
|
||||||
|
# on the target VM instead, as the user whose shell you want themed:
|
||||||
|
# scp scripts/install-starship-nerdfont.sh youruser@host:/tmp/
|
||||||
|
# ssh youruser@host 'bash /tmp/install-starship-nerdfont.sh'
|
||||||
|
# or paste its contents into a Terminal/SSH session and run it there.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NERD_FONT_VERSION="v3.4.0"
|
||||||
|
NERD_FONT_NAME="JetBrainsMono"
|
||||||
|
FONT_DIR="${HOME}/.local/share/fonts/NerdFonts"
|
||||||
|
|
||||||
|
echo "==> Installing Starship"
|
||||||
|
if command -v starship >/dev/null 2>&1; then
|
||||||
|
echo " starship already installed ($(starship --version | head -1)), skipping"
|
||||||
|
else
|
||||||
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing ${NERD_FONT_NAME} Nerd Font into ${FONT_DIR}"
|
||||||
|
mkdir -p "${FONT_DIR}"
|
||||||
|
TMP_TAR="$(mktemp)"
|
||||||
|
curl -fsSL -o "${TMP_TAR}" \
|
||||||
|
"https://github.com/ryanoasis/nerd-fonts/releases/download/${NERD_FONT_VERSION}/${NERD_FONT_NAME}.tar.xz"
|
||||||
|
tar -xJf "${TMP_TAR}" -C "${FONT_DIR}"
|
||||||
|
rm -f "${TMP_TAR}"
|
||||||
|
|
||||||
|
if command -v fc-cache >/dev/null 2>&1; then
|
||||||
|
fc-cache -f "${FONT_DIR}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
STARSHIP_INIT_BASH='eval "$(starship init bash)"'
|
||||||
|
STARSHIP_INIT_ZSH='eval "$(starship init zsh)"'
|
||||||
|
|
||||||
|
add_init_line() {
|
||||||
|
local rcfile="$1" line="$2"
|
||||||
|
[ -f "${rcfile}" ] || return 0
|
||||||
|
if ! grep -qF "${line}" "${rcfile}"; then
|
||||||
|
printf '\n# Added by ArchNest install-starship-nerdfont.sh\n%s\n' "${line}" >> "${rcfile}"
|
||||||
|
echo " added Starship init to ${rcfile}"
|
||||||
|
else
|
||||||
|
echo " ${rcfile} already initializes Starship, skipping"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Wiring Starship into shell rc files"
|
||||||
|
add_init_line "${HOME}/.bashrc" "${STARSHIP_INIT_BASH}"
|
||||||
|
add_init_line "${HOME}/.zshrc" "${STARSHIP_INIT_ZSH}"
|
||||||
|
|
||||||
|
mkdir -p "${HOME}/.config"
|
||||||
|
if [ ! -f "${HOME}/.config/starship.toml" ]; then
|
||||||
|
starship preset nerd-font-symbols -o "${HOME}/.config/starship.toml" 2>/dev/null \
|
||||||
|
|| echo " (preset command unavailable on this Starship version — using its built-in default prompt, which already includes icons)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. In ArchNest's Terminal page, open Settings (gear icon) and pick a font —
|
||||||
|
any of the listed options now layers in icon glyphs automatically.
|
||||||
|
2. Open a NEW terminal session to this host (or run 'exec \$SHELL -l') to
|
||||||
|
pick up the new prompt — it won't apply to sessions already connected.
|
||||||
|
|
||||||
|
Font installed to: ${FONT_DIR}
|
||||||
|
Starship config: ${HOME}/.config/starship.toml
|
||||||
|
EOF
|
||||||
|
|
@ -70,7 +70,7 @@ export default function BottomRow() {
|
||||||
) : integrations.length === 0 ? (
|
) : integrations.length === 0 ? (
|
||||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet — add one in Settings.</p>
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet — add one in Settings.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="scrollbar-ghost grid grid-cols-5 gap-2" style={{ maxHeight: '168px', overflowY: 'auto', paddingRight: '4px' }}>
|
||||||
{integrations.map((i) => (
|
{integrations.map((i) => (
|
||||||
<div key={i.id} className="flex items-center gap-2.5" style={{ padding: '8px 10px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)' }}>
|
<div key={i.id} className="flex items-center gap-2.5" style={{ padding: '8px 10px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)' }}>
|
||||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[i.status] ?? '#7A7D85', flexShrink: 0 }} />
|
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[i.status] ?? '#7A7D85', flexShrink: 0 }} />
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,53 @@ export default function ProgressRing({ percentage, size = 56, strokeWidth = 4 }:
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RingSegment {
|
||||||
|
color: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiSegmentRingProps {
|
||||||
|
segments: RingSegment[]
|
||||||
|
total: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
centerLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSegmentRing({ segments, total, size = 64, strokeWidth = 10, centerLabel }: MultiSegmentRingProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
|
||||||
|
let cumulative = 0
|
||||||
|
const drawn = segments.filter((s) => s.value > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#1E2025" strokeWidth={strokeWidth} />
|
||||||
|
{total > 0 &&
|
||||||
|
drawn.map((seg, i) => {
|
||||||
|
const segLen = (seg.value / total) * circumference
|
||||||
|
const offset = circumference - cumulative
|
||||||
|
cumulative += segLen
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={seg.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${segLen} ${circumference - segLen}`}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-all duration-1000 ease-out"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{centerLabel && <span className="absolute text-sm font-bold text-text-primary">{centerLabel}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Server, Plug, BookMarked } from 'lucide-react'
|
import { Server, Plug, BookMarked } from 'lucide-react'
|
||||||
import ProgressRing from './ProgressRing'
|
import { MultiSegmentRing } from './ProgressRing'
|
||||||
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
|
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
|
||||||
|
import { getIntegrationTypeColors } from '../lib/integrationColors'
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||||
|
|
@ -47,17 +48,34 @@ export default function StatusCards() {
|
||||||
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
|
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
|
||||||
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
|
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
|
||||||
|
|
||||||
|
const typeColors = getIntegrationTypeColors(integrations ?? [])
|
||||||
|
const typeCounts = new Map<string, number>()
|
||||||
|
for (const i of integrations ?? []) {
|
||||||
|
typeCounts.set(i.type, (typeCounts.get(i.type) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const typeSegments = [...typeCounts.entries()].map(([type, value]) => ({ type, value, color: typeColors[type] }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full grid-cols-4 gap-5">
|
<div className="grid w-full grid-cols-4 gap-5">
|
||||||
{/* System Status */}
|
{/* System Status */}
|
||||||
<div style={cardStyle} className="hover:!border-gold/20">
|
<div style={cardStyle} className="hover:!border-gold/20">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex h-full items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 style={labelStyle}>System Status</h3>
|
<h3 style={labelStyle}>System Status</h3>
|
||||||
<p style={{ fontSize: '16px', fontWeight: 700, color: errored > 0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}</p>
|
<p style={{ fontSize: '16px', fontWeight: 700, color: errored > 0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}</p>
|
||||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{connected} of {total} integrations connected</p>
|
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{connected} of {total} integrations connected</p>
|
||||||
|
{typeSegments.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap" style={{ marginTop: '8px' }}>
|
||||||
|
{typeSegments.map((s) => (
|
||||||
|
<span key={s.type} className="flex items-center gap-1">
|
||||||
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: s.color }} />
|
||||||
|
<span style={{ fontSize: '9px', color: '#7A7D85', textTransform: 'capitalize' }}>{s.type} ({s.value})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ProgressRing percentage={systemPercent} size={44} strokeWidth={3} />
|
<MultiSegmentRing segments={typeSegments} total={total} size={72} strokeWidth={11} centerLabel={`${systemPercent}%`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,16 @@ export const api = {
|
||||||
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
|
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Shell prompt setup on an SSH host: install Starship + a Nerd Font on demand
|
||||||
|
// from the Terminal page, instead of requiring a manual script run.
|
||||||
|
getShellPromptStatus: (integrationId: number) =>
|
||||||
|
apiFetch<{ presets: PromptPreset[]; status: PromptStatus }>(`/ssh/${integrationId}/shell-prompt`),
|
||||||
|
installShellPrompt: (integrationId: number, presetId: string) =>
|
||||||
|
apiFetch<{ ok: boolean; log: string }>(`/ssh/${integrationId}/shell-prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ presetId }),
|
||||||
|
}),
|
||||||
|
|
||||||
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
|
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
|
||||||
|
|
||||||
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
|
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
|
||||||
|
|
@ -209,6 +219,20 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PromptPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
starshipPreset: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptStatus {
|
||||||
|
starshipInstalled: boolean
|
||||||
|
starshipVersion: string | null
|
||||||
|
fontInstalled: boolean
|
||||||
|
configuredPreset: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataExport {
|
export interface DataExport {
|
||||||
version: number
|
version: number
|
||||||
exportedAt?: string
|
exportedAt?: string
|
||||||
|
|
|
||||||
24
src/lib/integrationColors.ts
Normal file
24
src/lib/integrationColors.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { Integration } from './api'
|
||||||
|
|
||||||
|
// Color-blind-friendly palette (Blue/Orange/Green/Brown/Purple/Red/Teal/Yellow),
|
||||||
|
// assigned to integration *types* in first-come-first-served order (by integration
|
||||||
|
// id, i.e. whichever type was connected to ArchNest first). Once a type has a
|
||||||
|
// color it keeps it everywhere — System Status breakdown, Infrastructure, etc.
|
||||||
|
const PALETTE = ['#4A90E2', '#E67E22', '#2ECC71', '#8B5E3C', '#9B59B6', '#E74C3C', '#1ABC9C', '#F1C40F']
|
||||||
|
|
||||||
|
export function getIntegrationTypeColors(integrations: Integration[]): Record<string, string> {
|
||||||
|
const sorted = [...integrations].sort((a, b) => a.id - b.id)
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
let next = 0
|
||||||
|
for (const integ of sorted) {
|
||||||
|
if (!(integ.type in map)) {
|
||||||
|
map[integ.type] = PALETTE[next % PALETTE.length]
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntegrationColor(integrations: Integration[], type: string): string {
|
||||||
|
return getIntegrationTypeColors(integrations)[type] ?? '#7A7D85'
|
||||||
|
}
|
||||||
|
|
@ -160,7 +160,7 @@ const quickStartSteps = [
|
||||||
|
|
||||||
export default function Help() {
|
export default function Help() {
|
||||||
return (
|
return (
|
||||||
<div className="p-8" style={{ maxWidth: '1100px' }}>
|
<div className="scrollbar-ghost h-full overflow-y-auto p-8" style={{ maxWidth: '1100px' }}>
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<h1 style={{ fontSize: '22px', color: '#E8E6E0', fontWeight: 700, marginBottom: '6px' }}>How ArchNest works</h1>
|
<h1 style={{ fontSize: '22px', color: '#E8E6E0', fontWeight: 700, marginBottom: '6px' }}>How ArchNest works</h1>
|
||||||
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react'
|
import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash, Sparkles } from 'lucide-react'
|
||||||
import { api, type Integration } from '../lib/api'
|
import { api, type Integration, type PromptPreset, type PromptStatus } from '../lib/api'
|
||||||
import { useTerminalSessions } from '../lib/TerminalSessionContext'
|
import { useTerminalSessions } from '../lib/TerminalSessionContext'
|
||||||
import { TERM_THEMES } from '../lib/terminalPrefs'
|
import { TERM_THEMES } from '../lib/terminalPrefs'
|
||||||
|
|
||||||
|
|
@ -174,6 +174,9 @@ export default function Terminal() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="ml-auto border-l border-white/10 pl-4">
|
||||||
|
<ShellPromptControl hostId={activePaneHostId} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -260,3 +263,83 @@ function TerminalPane({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a user pick one of a few Starship prompt looks and install it (Starship
|
||||||
|
* + a Nerd Font, if not already present) on the active pane's SSH host with
|
||||||
|
* one click, instead of running a script by hand.
|
||||||
|
*/
|
||||||
|
function ShellPromptControl({ hostId }: { hostId: number | null }) {
|
||||||
|
const [presets, setPresets] = useState<PromptPreset[]>([])
|
||||||
|
const [status, setStatus] = useState<PromptStatus | null>(null)
|
||||||
|
const [selected, setSelected] = useState('')
|
||||||
|
const [installing, setInstalling] = useState(false)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatus(null)
|
||||||
|
setMessage(null)
|
||||||
|
setError(null)
|
||||||
|
if (hostId === null) return
|
||||||
|
api
|
||||||
|
.getShellPromptStatus(hostId)
|
||||||
|
.then(({ presets, status }) => {
|
||||||
|
setPresets(presets)
|
||||||
|
setStatus(status)
|
||||||
|
setSelected(status.configuredPreset ?? presets[0]?.id ?? '')
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to check host'))
|
||||||
|
}, [hostId])
|
||||||
|
|
||||||
|
if (hostId === null) {
|
||||||
|
return <span style={{ color: TEXT_SECONDARY }}>Connect a host to set up its shell prompt</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstall() {
|
||||||
|
if (!selected) return
|
||||||
|
setInstalling(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await api.installShellPrompt(hostId!, selected)
|
||||||
|
setMessage('Installed — open a new terminal session to this host to see it.')
|
||||||
|
const { status } = await api.getShellPromptStatus(hostId!)
|
||||||
|
setStatus(status)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Install failed')
|
||||||
|
} finally {
|
||||||
|
setInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={13} style={{ color: GOLD }} />
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Shell Prompt
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
|
className="rounded-md border border-white/10 bg-transparent px-2 py-1"
|
||||||
|
>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id} title={p.description}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !selected}
|
||||||
|
className="rounded-md px-2.5 py-1 text-xs font-medium"
|
||||||
|
style={{ backgroundColor: GOLD, color: '#0A0A0C', opacity: installing ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{installing ? 'Installing…' : status?.configuredPreset === selected ? 'Reinstall' : 'Install'}
|
||||||
|
</button>
|
||||||
|
{message && <span style={{ color: '#2ECC71' }}>{message}</span>}
|
||||||
|
{error && <span style={{ color: '#E74C3C' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue