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:
@@ -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) {
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
|
||||
@@ -24,3 +24,22 @@ body {
|
||||
color: var(--foreground);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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';
|
||||
|
||||
interface ServiceStatus {
|
||||
@@ -15,12 +15,10 @@ interface ServiceStatus {
|
||||
history?: Array<{ hour: string; up: boolean }>;
|
||||
}
|
||||
|
||||
// Build exactly 24 bars from whatever history data exists
|
||||
function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) {
|
||||
const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = [];
|
||||
const now = new Date();
|
||||
|
||||
// Create a map of hour-string -> up/down from available data
|
||||
const hourMap = new Map<string, boolean>();
|
||||
if (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--) {
|
||||
const d = new Date(now);
|
||||
d.setMinutes(0, 0, 0);
|
||||
d.setHours(d.getHours() - i);
|
||||
// Format to match the API: "YYYY-MM-DD HH:00"
|
||||
const key = d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).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({
|
||||
service,
|
||||
onClose,
|
||||
onDelete,
|
||||
onRenamed,
|
||||
}: {
|
||||
service: ServiceStatus;
|
||||
onClose: () => void;
|
||||
onDelete: (name: string) => void;
|
||||
onRenamed: () => void;
|
||||
}) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -102,8 +183,31 @@ function DetailModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<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>
|
||||
<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>
|
||||
<button onClick={onClose} className="text-neutral-500 hover:text-white transition-colors p-1 rounded-lg hover:bg-neutral-800">
|
||||
<X size={18} />
|
||||
@@ -112,7 +216,7 @@ function DetailModal({
|
||||
|
||||
{/* 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">
|
||||
<ExternalLink size={12} />
|
||||
<ExternalLink size={12} className="shrink-0" />
|
||||
<span className="truncate">{service.url}</span>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +315,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
|
||||
return (
|
||||
<button
|
||||
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} />
|
||||
Add service
|
||||
@@ -220,7 +324,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-3 space-y-2">
|
||||
<form onSubmit={handleSubmit} className="space-y-2 pt-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -228,6 +332,7 @@ function AddServiceForm({ onAdded }: { onAdded: () => void }) {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
@@ -265,6 +370,7 @@ export function UptimeCard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -289,7 +395,6 @@ export function UptimeCard() {
|
||||
|
||||
const handleDelete = async (serviceName: string) => {
|
||||
try {
|
||||
// Look up ID from the services API
|
||||
const listRes = await fetch('/api/services');
|
||||
const list = await listRes.json();
|
||||
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 (
|
||||
<>
|
||||
<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="flex justify-between items-start">
|
||||
<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`}>
|
||||
{/* 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">
|
||||
<Activity size={18} />
|
||||
<span className="text-sm font-medium">Uptime Monitor</span>
|
||||
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
||||
</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>
|
||||
|
||||
{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} />
|
||||
<span className="text-xs">Failed to load status</span>
|
||||
</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) => (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<InlineNameEditor service={service} onRenamed={fetchStatus} />
|
||||
<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'}`}>
|
||||
{service.status === 'up' ? 'UP' : 'DOWN'}
|
||||
</span>
|
||||
@@ -358,6 +474,7 @@ export function UptimeCard() {
|
||||
service={selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
onDelete={handleDelete}
|
||||
onRenamed={fetchStatus}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user