diff --git a/backend/src/routes/shellPrompt.ts b/backend/src/routes/shellPrompt.ts new file mode 100644 index 0000000..bbd1a0a --- /dev/null +++ b/backend/src/routes/shellPrompt.ts @@ -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 } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index f20d074..9b7d9d9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,6 +13,7 @@ import { tunnelRoutes } from './routes/tunnels.js' import { fileRoutes } from './routes/files.js' import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' import { dockerSshRoutes, dockerSshExecRoutes } from './routes/dockerSsh.js' +import { shellPromptRoutes } from './routes/shellPrompt.js' import { agentIngestRoutes, agentRoutes } from './routes/agents.js' import { guacamoleRoutes } from './routes/guacamole.js' import { metricsRoutes } from './routes/metrics.js' @@ -94,6 +95,7 @@ await app.register(dockerRoutes) await app.register(dockerExecRoutes) await app.register(dockerSshRoutes) await app.register(dockerSshExecRoutes) +await app.register(shellPromptRoutes) await app.register(agentIngestRoutes) await app.register(agentRoutes) await app.register(guacamoleRoutes) diff --git a/backend/src/ssh/shellPrompt.ts b/backend/src/ssh/shellPrompt.ts new file mode 100644 index 0000000..c14df6d --- /dev/null +++ b/backend/src/ssh/shellPrompt.ts @@ -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 `. */ + 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 { + 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() } +} diff --git a/scripts/install-starship-nerdfont.sh b/scripts/install-starship-nerdfont.sh new file mode 100755 index 0000000..d1419d2 --- /dev/null +++ b/scripts/install-starship-nerdfont.sh @@ -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 <No integrations added yet — add one in Settings.

) : ( -
+
{integrations.map((i) => (
diff --git a/src/components/ProgressRing.tsx b/src/components/ProgressRing.tsx index bca4d76..c0c8a17 100644 --- a/src/components/ProgressRing.tsx +++ b/src/components/ProgressRing.tsx @@ -49,3 +49,53 @@ export default function ProgressRing({ percentage, size = 56, strokeWidth = 4 }:
) } + +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 ( +
+ + + {total > 0 && + drawn.map((seg, i) => { + const segLen = (seg.value / total) * circumference + const offset = circumference - cumulative + cumulative += segLen + return ( + + ) + })} + + {centerLabel && {centerLabel}} +
+ ) +} diff --git a/src/components/StatusCards.tsx b/src/components/StatusCards.tsx index 302288c..18c957a 100644 --- a/src/components/StatusCards.tsx +++ b/src/components/StatusCards.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from '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 { getIntegrationTypeColors } from '../lib/integrationColors' const cardStyle: React.CSSProperties = { 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 systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100) + const typeColors = getIntegrationTypeColors(integrations ?? []) + const typeCounts = new Map() + 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 (
{/* System Status */}
-
+

System Status

0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}

{connected} of {total} integrations connected

+ {typeSegments.length > 0 && ( +
+ {typeSegments.map((s) => ( + + + {s.type} ({s.value}) + + ))} +
+ )}
- +
diff --git a/src/lib/api.ts b/src/lib/api.ts index d945e03..dc76c9c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -193,6 +193,16 @@ export const api = { `/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(`/integrations/${integrationId}/metrics`), 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 { version: number exportedAt?: string diff --git a/src/lib/integrationColors.ts b/src/lib/integrationColors.ts new file mode 100644 index 0000000..2dc2404 --- /dev/null +++ b/src/lib/integrationColors.ts @@ -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 { + const sorted = [...integrations].sort((a, b) => a.id - b.id) + const map: Record = {} + 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' +} diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 9d72b0a..e82d00e 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -160,7 +160,7 @@ const quickStartSteps = [ export default function Help() { return ( -
+

How ArchNest works

diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 46c0026..408f663 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' -import { api, type Integration } from '../lib/api' +import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash, Sparkles } from 'lucide-react' +import { api, type Integration, type PromptPreset, type PromptStatus } from '../lib/api' import { useTerminalSessions } from '../lib/TerminalSessionContext' import { TERM_THEMES } from '../lib/terminalPrefs' @@ -174,6 +174,9 @@ export default function Terminal() { ))} +

+ +
)} @@ -260,3 +263,83 @@ function TerminalPane({
) } + +/** + * 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([]) + const [status, setStatus] = useState(null) + const [selected, setSelected] = useState('') + const [installing, setInstalling] = useState(false) + const [message, setMessage] = useState(null) + const [error, setError] = useState(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 Connect a host to set up its shell prompt + } + + 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 ( +
+ + + + {message && {message}} + {error && {error}} +
+ ) +}