Add scrollable widget, expand/collapse toggle, inline name editing

- Fix overflow by making card body scrollable with thin custom scrollbar
- Add expand/collapse chevron button toggling row-span-1 to row-span-2
- Add inline name editing on hover (pencil icon) with Enter/Escape/blur
- Add PATCH /api/services endpoint for renaming with log history update
- Add rename button in detail modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shivam Patel
2026-02-09 02:23:19 -05:00
parent 372ff8cd22
commit 8e18bf64b2
3 changed files with 187 additions and 18 deletions

View File

@@ -43,6 +43,39 @@ export async function POST(request: Request) {
} }
} }
export async function PATCH(request: Request) {
try {
const { id, name } = await request.json();
if (!id || !name) {
return NextResponse.json({ error: 'ID and new name are required' }, { status: 400 });
}
const db = await getDb();
const service = await db.get('SELECT name FROM monitored_services WHERE id = ?', id);
if (!service) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
const oldName = service.name;
const newName = name.trim();
await db.run('UPDATE monitored_services SET name = ? WHERE id = ?', newName, id);
// Update existing logs to match the new name
await db.run('UPDATE uptime_logs SET service_name = ? WHERE service_name = ?', newName, oldName);
return NextResponse.json({ success: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error';
if (msg.includes('UNIQUE constraint')) {
return NextResponse.json({ error: 'A service with that name already exists' }, { status: 409 });
}
console.error('Service rename error:', error);
return NextResponse.json({ error: 'Failed to rename service' }, { status: 500 });
}
}
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
const { id } = await request.json(); const { id } = await request.json();

View File

@@ -24,3 +24,22 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Thin scrollbar for widget overflow areas */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #404040 transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 2px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #525252;
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink } from 'lucide-react'; import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
interface ServiceStatus { interface ServiceStatus {
@@ -15,12 +15,10 @@ interface ServiceStatus {
history?: Array<{ hour: string; up: boolean }>; history?: Array<{ hour: string; up: boolean }>;
} }
// Build exactly 24 bars from whatever history data exists
function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) { function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) {
const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = []; const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = [];
const now = new Date(); const now = new Date();
// Create a map of hour-string -> up/down from available data
const hourMap = new Map<string, boolean>(); const hourMap = new Map<string, boolean>();
if (history) { if (history) {
for (const h of history) { for (const h of history) {
@@ -28,12 +26,10 @@ function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined)
} }
} }
// Generate 24 hour slots ending at current hour
for (let i = 23; i >= 0; i--) { for (let i = 23; i >= 0; i--) {
const d = new Date(now); const d = new Date(now);
d.setMinutes(0, 0, 0); d.setMinutes(0, 0, 0);
d.setHours(d.getHours() - i); d.setHours(d.getHours() - i);
// Format to match the API: "YYYY-MM-DD HH:00"
const key = d.getFullYear() + '-' + const key = d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + ' ' + String(d.getDate()).padStart(2, '0') + ' ' +
@@ -71,14 +67,99 @@ function HistoryBars({ history, large }: { history?: Array<{ hour: string; up: b
); );
} }
function InlineNameEditor({ service, onRenamed }: { service: ServiceStatus; onRenamed: () => void }) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(service.name);
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const save = async () => {
const trimmed = name.trim();
if (!trimmed || trimmed === service.name) {
setName(service.name);
setEditing(false);
return;
}
setSaving(true);
try {
const listRes = await fetch('/api/services');
const list = await listRes.json();
const svc = list.find((s: { name: string }) => s.name === service.name);
if (!svc) return;
const res = await fetch('/api/services', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: svc.id, name: trimmed }),
});
if (res.ok) {
setEditing(false);
onRenamed();
} else {
const data = await res.json();
alert(data.error || 'Rename failed');
setName(service.name);
setEditing(false);
}
} catch {
setName(service.name);
setEditing(false);
} finally {
setSaving(false);
}
};
if (!editing) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors truncate">{service.name}</span>
<button
onClick={(e) => { e.stopPropagation(); setEditing(true); }}
className="opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity text-neutral-400 hover:text-white shrink-0"
title="Rename"
>
<Pencil size={11} />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') { setName(service.name); setEditing(false); }
}}
onBlur={save}
disabled={saving}
className="bg-neutral-800 border border-neutral-600 rounded px-1.5 py-0.5 text-sm text-white font-medium focus:outline-none focus:border-neutral-400 w-24 min-w-0"
/>
<button onClick={save} disabled={saving} className="text-emerald-500 hover:text-emerald-400 shrink-0">
<Check size={13} />
</button>
</div>
);
}
function DetailModal({ function DetailModal({
service, service,
onClose, onClose,
onDelete, onDelete,
onRenamed,
}: { }: {
service: ServiceStatus; service: ServiceStatus;
onClose: () => void; onClose: () => void;
onDelete: (name: string) => void; onDelete: (name: string) => void;
onRenamed: () => void;
}) { }) {
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
@@ -102,8 +183,31 @@ function DetailModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${service.status === 'up' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} /> <div className={`w-2.5 h-2.5 rounded-full shrink-0 ${service.status === 'up' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} />
<h2 className="text-lg font-semibold text-white">{service.name}</h2> <h2 className="text-lg font-semibold text-white">{service.name}</h2>
<button
onClick={() => {
const newName = prompt('Rename service:', service.name);
if (newName && newName.trim() && newName.trim() !== service.name) {
(async () => {
const listRes = await fetch('/api/services');
const list = await listRes.json();
const svc = list.find((s: { name: string }) => s.name === service.name);
if (!svc) return;
const res = await fetch('/api/services', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: svc.id, name: newName.trim() }),
});
if (res.ok) { onRenamed(); onClose(); }
})();
}
}}
className="text-neutral-500 hover:text-neutral-300 transition-colors"
title="Rename"
>
<Pencil size={14} />
</button>
</div> </div>
<button onClick={onClose} className="text-neutral-500 hover:text-white transition-colors p-1 rounded-lg hover:bg-neutral-800"> <button onClick={onClose} className="text-neutral-500 hover:text-white transition-colors p-1 rounded-lg hover:bg-neutral-800">
<X size={18} /> <X size={18} />
@@ -112,7 +216,7 @@ function DetailModal({
{/* URL */} {/* URL */}
<div className="flex items-center gap-2 mb-5 text-xs text-neutral-500 font-mono bg-neutral-800/50 rounded-lg px-3 py-2"> <div className="flex items-center gap-2 mb-5 text-xs text-neutral-500 font-mono bg-neutral-800/50 rounded-lg px-3 py-2">
<ExternalLink size={12} /> <ExternalLink size={12} className="shrink-0" />
<span className="truncate">{service.url}</span> <span className="truncate">{service.url}</span>
</div> </div>
@@ -211,7 +315,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
return ( return (
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors mt-3" className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors pt-2"
> >
<Plus size={14} /> <Plus size={14} />
Add service Add service
@@ -220,7 +324,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
} }
return ( return (
<form onSubmit={handleSubmit} className="mt-3 space-y-2"> <form onSubmit={handleSubmit} className="space-y-2 pt-2">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -228,6 +332,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
autoFocus
className="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" className="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500"
/> />
<input <input
@@ -265,6 +370,7 @@ export function UptimeCard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null); const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
const [expanded, setExpanded] = useState(false);
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
try { try {
@@ -289,7 +395,6 @@ export function UptimeCard() {
const handleDelete = async (serviceName: string) => { const handleDelete = async (serviceName: string) => {
try { try {
// Look up ID from the services API
const listRes = await fetch('/api/services'); const listRes = await fetch('/api/services');
const list = await listRes.json(); const list = await listRes.json();
const svc = list.find((s: { name: string }) => s.name === serviceName); const svc = list.find((s: { name: string }) => s.name === serviceName);
@@ -306,33 +411,44 @@ export function UptimeCard() {
} }
}; };
const rowSpan = expanded ? 'row-span-2' : 'row-span-1';
return ( return (
<> <>
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors"> <div className={`col-span-1 md:col-span-2 lg:col-span-2 ${rowSpan} bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-all duration-300 overflow-hidden`}>
<div className="flex justify-between items-start"> {/* Header — always visible, never scrolls */}
<div className="flex justify-between items-center shrink-0 mb-3">
<div className="flex items-center gap-2 text-neutral-400"> <div className="flex items-center gap-2 text-neutral-400">
<Activity size={18} /> <Activity size={18} />
<span className="text-sm font-medium">Uptime Monitor</span> <span className="text-sm font-medium">Uptime Monitor</span>
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />} {loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
</div> </div>
<button
onClick={() => setExpanded(!expanded)}
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1 rounded-lg hover:bg-neutral-800"
title={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div> </div>
{error ? ( {error ? (
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2"> <div className="flex flex-col items-center justify-center flex-1 text-red-400 gap-2">
<AlertCircle size={24} /> <AlertCircle size={24} />
<span className="text-xs">Failed to load status</span> <span className="text-xs">Failed to load status</span>
</div> </div>
) : ( ) : (
<div className="space-y-4 mt-4"> /* Scrollable content area — fills remaining space */
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 pr-1 scrollbar-thin">
{services.map((service) => ( {services.map((service) => (
<div <div
key={service.name} key={service.name}
className="flex flex-col gap-1 cursor-pointer group" className="flex flex-col gap-1 cursor-pointer group rounded-lg hover:bg-neutral-800/40 px-2 py-1.5 -mx-2 transition-colors"
onClick={() => setSelectedService(service)} onClick={() => setSelectedService(service)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span> <InlineNameEditor service={service} onRenamed={fetchStatus} />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<span className={`text-xs px-1.5 py-0.5 rounded ${service.status === 'up' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${service.status === 'up' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{service.status === 'up' ? 'UP' : 'DOWN'} {service.status === 'up' ? 'UP' : 'DOWN'}
</span> </span>
@@ -358,6 +474,7 @@ export function UptimeCard() {
service={selectedService} service={selectedService}
onClose={() => setSelectedService(null)} onClose={() => setSelectedService(null)}
onDelete={handleDelete} onDelete={handleDelete}
onRenamed={fetchStatus}
/> />
)} )}
</> </>