8 Commits
Author SHA1 Message Date
valknar 94ffa9e930 chore: bump version to 0.4.7
CI / Static checks (push) Successful in 35s
CI / Build and push image (push) Successful in 1m32s
2026-08-19 08:46:02 +02:00
valknarandClaude Sonnet 5 491c0a9c7c fix: show live connection indicator on mobile
CI / Static checks (push) Successful in 1m8s
CI / Build and push image (push) Skipped
The header's live/connecting status was hidden below the 860px
breakpoint; keep it visible on mobile too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 08:45:38 +02:00
valknarandClaude Sonnet 5 1d51c5c887 chore: bump version to 0.4.6
CI / Static checks (push) Successful in 38s
CI / Build and push image (push) Successful in 1m30s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 16:58:56 +02:00
valknarandClaude Sonnet 5 2c84a4c9c2 fix: remove project name badges from the Coolify widget
Coolify was the only service reporting the generic detail field
(project name chips); drop it end to end - collector, type, and the
widget's chip row - rather than leave the plumbing for zero producers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 16:58:47 +02:00
valknarandClaude Sonnet 5 6ab3054d9a chore: bump version to 0.4.5
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m34s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:26:58 +02:00
valknarandClaude Sonnet 5 7549658fbb fix: bookmark widget sizing, generic globe icon, code-server brand icon
- multi-link bookmark cards now span 4/2 (matching the system widget)
  instead of 3/1, so they're not cramped and line up into an even
  3-up row alongside it
- the link list scrolls internally instead of stretching the card,
  so widgets in the same row stay equal height regardless of link count
- BrandIcon gains a small "generic icon" escape hatch (currently just
  `globe`, via phosphor) for widgets that don't have a real brand mark
- added a `coder` brand icon (Coder/code-server) for the Code Server link,
  since simple-icons has no official VS Code mark to use

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:26:45 +02:00
valknarandClaude Sonnet 5 d980e68577 chore: bump version to 0.4.4
CI / Static checks (push) Successful in 36s
CI / Build and push image (push) Successful in 1m30s
package.json had drifted out of sync with git tags since v0.4.0
(tags were cut without a matching package.json bump); this catches
it up and covers the bookmark links-list feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:15:51 +02:00
valknarandClaude Sonnet 5 5a33218235 feat: support a multi-link list in the bookmark widget
A bookmark widget can now carry an optional links array instead of
a single href, rendering as a compact card with one row per link -
for grouping several quick links (e.g. "all services", "all Coolify
apps") into one widget instead of one card per link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
2026-08-18 10:14:47 +02:00
11 changed files with 58 additions and 29 deletions
-3
View File
@@ -169,7 +169,4 @@ body {
grid-column: 1 / -1 !important; grid-column: 1 / -1 !important;
grid-row: auto !important; grid-row: auto !important;
} }
.pn-live-label {
display: none !important;
}
} }
+13
View File
@@ -1,7 +1,20 @@
import { GlobeSimple } from "@phosphor-icons/react/ssr";
import { BRAND_ICON_PATHS } from "@/lib/brand-icons"; import { BRAND_ICON_PATHS } from "@/lib/brand-icons";
// Generic (non-brand) icons that don't belong in BRAND_ICON_PATHS - for
// widgets that link to something with no single logo of its own.
const GENERIC_ICONS = {
globe: GlobeSimple,
};
export function BrandIcon({ slug, size = 18 }: { slug?: string; size?: number }) { export function BrandIcon({ slug, size = 18 }: { slug?: string; size?: number }) {
if (!slug) return null; if (!slug) return null;
const GenericIcon = GENERIC_ICONS[slug as keyof typeof GENERIC_ICONS];
if (GenericIcon) {
return <GenericIcon size={size} className="shrink-0 text-fg-muted" aria-hidden />;
}
const path = BRAND_ICON_PATHS[slug]; const path = BRAND_ICON_PATHS[slug];
if (!path) return null; if (!path) return null;
+31
View File
@@ -6,6 +6,37 @@ import { BrandIcon } from "@/components/widgets/BrandIcon";
type BookmarkWidget = Extract<Widget, { type: "bookmark" }>; type BookmarkWidget = Extract<Widget, { type: "bookmark" }>;
export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) { export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) {
if (widget.links && widget.links.length > 0) {
return (
<div className={PN_CARD_CLASS} style={{ gridColumn: "span 4", gridRow: "span 2" }}>
<div className="flex items-center gap-2">
<BrandIcon slug={widget.icon} />
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-fg">{widget.name}</span>
</div>
{widget.description && <span className="text-xs text-fg-muted">{widget.description}</span>}
<div className="-mx-1 flex min-h-0 flex-1 flex-col divide-y divide-border overflow-y-auto">
{widget.links.map((link) => (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-2 px-1 py-1.5 text-sm text-fg-muted no-underline hover:text-accent"
>
<BrandIcon slug={link.icon} size={14} />
<span className="min-w-0 flex-1 truncate">{link.name}</span>
<ArrowUpRight
size={13}
className="shrink-0 text-accent opacity-0 group-hover:opacity-100"
aria-hidden
/>
</a>
))}
</div>
</div>
);
}
return ( return (
<a <a
href={widget.href} href={widget.href}
-9
View File
@@ -60,15 +60,6 @@ export function ServiceWidget({ widget, widgetId }: { widget: ServiceWidget; wid
))} ))}
</div> </div>
)} )}
{data.detail && data.detail.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.detail.map((entry) => (
<StatusTag key={entry} tone="neutral">
{entry}
</StatusTag>
))}
</div>
)}
{data.statError && <StatusTag tone="degraded">{data.statError}</StatusTag>} {data.statError && <StatusTag tone="degraded">{data.statError}</StatusTag>}
{widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && ( {widget.showStats && data.docker.cpuPercent !== null && memPercent !== null && (
<div className="mt-0.5 flex gap-4"> <div className="mt-0.5 flex gap-4">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@ import { collectUmami } from "./services/umami";
import { collectHeadscale } from "./services/headscale"; import { collectHeadscale } from "./services/headscale";
import { collectTraefikStat } from "./services/traefik"; import { collectTraefikStat } from "./services/traefik";
function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[]; detail?: string[] }> { function collectServiceStat(widget: ServiceWidget): Promise<{ stats: ServiceStat[] }> {
switch (widget.service) { switch (widget.service) {
case "gitea": case "gitea":
return collectGitea(widget); return collectGitea(widget);
+2 -13
View File
@@ -2,12 +2,6 @@ import type { CoolifyServiceWidget } from "@/lib/config/schema";
import type { ServiceStat } from "@/lib/types/service-result"; import type { ServiceStat } from "@/lib/types/service-result";
import { serviceBaseUrl } from "./base-url"; import { serviceBaseUrl } from "./base-url";
const DETAIL_LIMIT = 5;
interface CoolifyProject {
name: string;
}
async function fetchCoolify<T>(base: string, path: string, apiToken: string): Promise<T> { async function fetchCoolify<T>(base: string, path: string, apiToken: string): Promise<T> {
const response = await fetch(`${base}${path}`, { const response = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${apiToken}` }, headers: { Authorization: `Bearer ${apiToken}` },
@@ -19,22 +13,17 @@ async function fetchCoolify<T>(base: string, path: string, apiToken: string): Pr
return (await response.json()) as T; return (await response.json()) as T;
} }
export async function collectCoolify( export async function collectCoolify(widget: CoolifyServiceWidget): Promise<{ stats: ServiceStat[] }> {
widget: CoolifyServiceWidget
): Promise<{ stats: ServiceStat[]; detail?: string[] }> {
const base = serviceBaseUrl(widget); const base = serviceBaseUrl(widget);
const [projects, resources] = await Promise.all([ const [projects, resources] = await Promise.all([
fetchCoolify<CoolifyProject[]>(base, "/api/v1/projects", widget.apiToken), fetchCoolify<unknown[]>(base, "/api/v1/projects", widget.apiToken),
fetchCoolify<unknown[]>(base, "/api/v1/resources", widget.apiToken), fetchCoolify<unknown[]>(base, "/api/v1/resources", widget.apiToken),
]); ]);
const names = projects.slice(0, DETAIL_LIMIT).map((p) => p.name);
if (projects.length > DETAIL_LIMIT) names.push(`+${projects.length - DETAIL_LIMIT} more`);
return { return {
stats: [ stats: [
{ label: "Projects", value: String(projects.length) }, { label: "Projects", value: String(projects.length) },
{ label: "Resources", value: String(resources.length) }, { label: "Resources", value: String(resources.length) },
], ],
detail: names.length > 0 ? names : undefined,
}; };
} }
+8 -1
View File
@@ -6,12 +6,19 @@ const durationSchema = z
.string() .string()
.regex(/^\d+(ms|s|m|h)$/, 'Expected a duration like "500ms", "5s", "1m", or "1h"'); .regex(/^\d+(ms|s|m|h)$/, 'Expected a duration like "500ms", "5s", "1m", or "1h"');
export const bookmarkLinkSchema = z.object({
name: z.string(),
href: z.string().url(),
icon: z.string().optional(),
});
export const bookmarkWidgetSchema = z.object({ export const bookmarkWidgetSchema = z.object({
type: z.literal("bookmark"), type: z.literal("bookmark"),
name: z.string(), name: z.string(),
href: z.string().url(), href: z.string().url().optional(),
description: z.string().optional(), description: z.string().optional(),
icon: z.string().optional(), icon: z.string().optional(),
links: z.array(bookmarkLinkSchema).optional(),
}); });
export const searchWidgetSchema = z.object({ export const searchWidgetSchema = z.object({
-1
View File
@@ -8,6 +8,5 @@ export interface ServiceStat {
export interface ServiceWidgetResult { export interface ServiceWidgetResult {
docker: DockerContainerResult; docker: DockerContainerResult;
stats: ServiceStat[]; stats: ServiceStat[];
detail?: string[];
statError?: string; statError?: string;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulsenode", "name": "pulsenode",
"version": "0.3.0", "version": "0.4.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch server.ts", "dev": "tsx watch server.ts",
+1
View File
@@ -26,6 +26,7 @@ ICONS = {
"postgresql": "postgresql", "postgresql": "postgresql",
"redis": "redis", "redis": "redis",
"headscale": "tailscale", "headscale": "tailscale",
"coder": "coder",
} }
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent