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) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user