Compare commits

...

12 commits
v1.12.2 ... dev

Author SHA1 Message Date
Vadym Samoilenko
7d80b21c1b fix: pass deploy widget fields through config whitelist, fix siteMonitor URLs
Some checks failed
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Release Drafter / Auto Label PR (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
- Add service/label/apiBase to service-helpers.js whitelist for deploy type
  (these keys were being stripped, causing deploy widget to show nothing)
- Add trailing slashes to gsb/semblance/cc-dashboard siteMonitor URLs
  to prevent 301 redirect to http:// which was causing red dots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:22:11 +01:00
Vadym Samoilenko
7e6d8517b0 feat: move deploy buttons into service cards, fix siteMonitor URLs
- Create src/widgets/deploy/component.jsx — service-card widget variant
  that receives { service } prop (not { options }) for use in services.yaml
- Register deploy in src/widgets/components.js
- Move deploy widgets from config/widgets.yaml into each service card
  via widget: type: deploy — buttons now live inline under each app
- Fix Deploy API siteMonitor URL to use Apache-proxied path
- Reduce Widgets layout columns to 2 (only resources + datetime remain)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:12:46 +01:00
Vadym Samoilenko
370ea5dae9 Config: overhaul dashboard design and layout
- zinc color, fullWidth, useEqualHeights, cardBlur md, statusStyle dot
- siteMonitor (HTTP + response time) instead of ping for all web services
- showStats: true — live CPU/RAM per Docker container on each card
- Add DB/cache info cards to Infrastructure section
- Clean up bookmarks, add OliVAS repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:06:26 +01:00
Vadym Samoilenko
8874bc604c Fix: remove double basePath prefix in deploy widget SWR key
SWR middleware already prepends NEXT_PUBLIC_BASE_PATH; statusUrl
must start with /deploy-api/... not /${bp}/deploy-api/...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:59:42 +01:00
Vadym Samoilenko
81fb160eb5 Config: improve dashboard layout, add healthchecks and bookmarks
- settings.yaml: cardBlur, hideVersion, target _blank, clean layout
- services.yaml: add ping healthcheck URLs for all services + Deploy API card
- bookmarks.yaml: Bitbucket repos and server shortcuts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:56:25 +01:00
Vadym Samoilenko
cd66562e9d Feat: add deploy widget with one-click deploy buttons
- components/widgets/deploy/deploy.jsx: new widget with deploy button,
  status polling (2s when running, 10s idle), last deploy time display
- widget.jsx: register 'deploy' widget type
- config/widgets.yaml: add deploy buttons for all 6 server services

Deploy API runs as systemd service on host :9000, proxied via Apache
at /deploy-api/. Widget polls GET /status/{service} and triggers
POST /deploy/{service} on button click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:53:45 +01:00
Vadym Samoilenko
5eb4a2908d Fix: set HOMEPAGE_ALLOWED_HOSTS to specific domain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:49:30 +01:00
Vadym Samoilenko
512edd4e42 Fix: use HOMEPAGE_ALLOWED_HOSTS env var for host validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:47:36 +01:00
Vadym Samoilenko
03e9c9fb13 Fix: allow optical-dev.oliver.solutions host in Next.js 15+ validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:46:48 +01:00
Vadym Samoilenko
d71fbe6c75 Fix: prepend basePath to all client-side API calls + add server config
API path fixes:
- _app.jsx: SWR middleware prefixes all useSWR keys with NEXT_PUBLIC_BASE_PATH
- index.jsx: update fallback keys, raw fetch() calls, and Script src
- _document.jsx: update custom.css link hrefs
- revalidate.jsx, search.jsx, api-helpers.js: update raw fetch/proxy calls

Initial dashboard config (force-added, config/ is gitignored by default):
- settings.yaml: dark slate theme, English, two-row layout
- services.yaml: all 5 server apps with Docker container health links
- widgets.yaml: CPU/RAM/disk resources widget + datetime
- docker.yaml: local Docker socket for auto-discovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:43:03 +01:00
Vadym Samoilenko
c4129189bb Fix: pass NEXT_PUBLIC_BASE_PATH build arg through Dockerfile
Declares ARG NEXT_PUBLIC_BASE_PATH in builder stage and passes it
inline to pnpm build so Next.js basePath is applied at compile time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:33:42 +01:00
Vadym Samoilenko
be5e2a5e7c Deploy: add basePath support and server docker-compose
- next.config.js: basePath from NEXT_PUBLIC_BASE_PATH env var (build arg)
- docker-compose.yml: builds with /homepage base path, exposes :3001 locally, mounts config dir and Docker socket
- deploy.sh: clone/pull + build + start script for optical-dev server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:27:45 +01:00
20 changed files with 416 additions and 15 deletions

View file

@ -12,6 +12,7 @@ ARG CI
ARG BUILDTIME
ARG VERSION
ARG REVISION
ARG NEXT_PUBLIC_BASE_PATH
ENV CI=$CI
# Install and build only outside CI
@ -22,6 +23,7 @@ RUN if [ "$CI" != "true" ]; then \
NEXT_PUBLIC_BUILDTIME=$BUILDTIME \
NEXT_PUBLIC_VERSION=$VERSION \
NEXT_PUBLIC_REVISION=$REVISION \
NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH \
pnpm run build; \
else \
echo "✅ Using prebuilt app from CI context"; \

14
config/bookmarks.yaml Normal file
View file

@ -0,0 +1,14 @@
---
- Repositories:
- DeckForge:
- abbr: DF
href: https://bitbucket.org/zlalani/ppt-tool
- GMAL Scope Builder:
- abbr: GSB
href: https://bitbucket.org/zlalani/gmal-scope-builder
- Homepage:
- abbr: HP
href: https://bitbucket.org/zlalani/homepage
- OliVAS:
- abbr: OL
href: https://bitbucket.org/zlalani/olivas

3
config/docker.yaml Normal file
View file

@ -0,0 +1,3 @@
---
local:
socket: /var/run/docker.sock

96
config/services.yaml Normal file
View file

@ -0,0 +1,96 @@
---
- AI Tools:
- DeckForge:
icon: mdi-presentation
href: https://optical-dev.oliver.solutions/ppt-tool
description: AI presentation generator
container: ppt-tool-web-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/ppt-tool
showStats: true
widget:
type: deploy
service: ppt-tool
label: DeckForge
- GMAL Scope Builder:
icon: mdi-briefcase-outline
href: https://optical-dev.oliver.solutions/gsb
description: AI ratecard & team scoping
container: gmal-scope-builder-backend-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/gsb/
showStats: true
widget:
type: deploy
service: gmal-scope-builder
label: Scope Builder
- Semblance:
icon: mdi-account-group-outline
href: https://optical-dev.oliver.solutions/semblance
description: Synthetic personas & focus groups
container: semblance-backend-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/semblance/
showStats: true
widget:
type: deploy
service: semblance
label: Semblance
- CC Dashboard:
icon: mdi-view-dashboard-outline
href: https://optical-dev.oliver.solutions/cc-dashboard
description: API key & project management
container: cc-dashboard-app-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/cc-dashboard/
showStats: true
widget:
type: deploy
service: cc-dashboard
label: CC Dashboard
- OliVAS:
icon: mdi-robot-outline
href: https://optical-dev.oliver.solutions
description: OliVAS backend API
container: olivas-backend-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/api/health
showStats: true
widget:
type: deploy
service: olivas
label: OliVAS
- Infrastructure:
- Homepage:
icon: mdi-home-outline
href: https://optical-dev.oliver.solutions/homepage
description: This dashboard
container: homepage-app-1
server: local
siteMonitor: https://optical-dev.oliver.solutions/homepage
showStats: true
widget:
type: deploy
service: homepage
label: Homepage
- Deploy API:
icon: mdi-rocket-launch-outline
href: https://optical-dev.oliver.solutions/deploy-api/docs
description: One-click deploy service
siteMonitor: https://optical-dev.oliver.solutions/deploy-api/services
- PostgreSQL × 4:
icon: mdi-database-outline
description: "ppt-tool · olivas · cc-dashboard · gmal"
href: https://optical-dev.oliver.solutions
- Redis + MongoDB:
icon: mdi-server-outline
description: ppt-tool (Redis) · semblance (Mongo)
href: https://optical-dev.oliver.solutions

33
config/settings.yaml Normal file
View file

@ -0,0 +1,33 @@
---
title: Optical Dev
language: en
theme: dark
color: zinc
# Full-width layout, equal card heights, hide noise
fullWidth: true
useEqualHeights: true
headerStyle: clean
cardBlur: md
statusStyle: dot
hideVersion: true
disableUpdateCheck: true
target: _blank
# Layout sections
layout:
Widgets:
style: row
columns: 2
AI Tools:
style: row
columns: 5
useEqualHeights: true
Infrastructure:
style: row
columns: 4
useEqualHeights: true
Repositories:
style: row
columns: 4
iconsOnly: false

15
config/widgets.yaml Normal file
View file

@ -0,0 +1,15 @@
---
- resources:
cpu: true
memory: true
disk: /
label: optical-dev server
expanded: true
cputemp: false
- datetime:
text_size: xl
format:
timeStyle: short
dateStyle: short
hourCycle: h23

30
deploy.sh Normal file
View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
DEPLOY_DIR="/opt/homepage"
REPO="git@bitbucket.org:zlalani/homepage.git"
echo "=== Homepage Deploy ==="
if [ ! -d "$DEPLOY_DIR/.git" ]; then
echo "Cloning repository..."
git clone "$REPO" "$DEPLOY_DIR"
cd "$DEPLOY_DIR"
else
echo "Pulling latest changes..."
cd "$DEPLOY_DIR"
git pull origin dev
fi
mkdir -p config
echo "Building and starting container..."
docker compose build --no-cache
docker compose up -d
echo "Waiting for healthcheck..."
sleep 15
docker compose ps
echo ""
echo "Done. Homepage available at https://optical-dev.oliver.solutions/homepage"

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
services:
app:
build:
context: .
args:
NEXT_PUBLIC_BASE_PATH: /homepage
ports:
- "127.0.0.1:3001:3000"
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
HOSTNAME: "::"
HOMEPAGE_ALLOWED_HOSTS: "optical-dev.oliver.solutions"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthcheck || exit 1"]
interval: 10s
timeout: 3s
start_period: 30s
retries: 3

View file

@ -4,6 +4,7 @@ const { i18n } = require("./next-i18next.config");
const nextConfig = {
reactStrictMode: true,
output: "standalone",
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
images: {
remotePatterns: [
{

View file

@ -2,7 +2,7 @@ import { MdRefresh } from "react-icons/md";
export default function Revalidate() {
const revalidate = () => {
fetch("/api/revalidate").then((res) => {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/revalidate`).then((res) => {
if (res.ok) {
window.location.reload();
}

View file

@ -0,0 +1,88 @@
import { useState } from "react";
import useSWR from "swr";
const STATUS_COLORS = {
idle: "text-theme-500 dark:text-theme-400",
running: "text-blue-500 dark:text-blue-400",
success: "text-emerald-500 dark:text-emerald-400",
failed: "text-red-500 dark:text-red-400",
};
const STATUS_LABELS = {
idle: "Never deployed",
running: "Deploying...",
success: "Deployed",
failed: "Failed",
};
function formatTime(iso) {
if (!iso) return null;
const d = new Date(iso);
return d.toLocaleString("en-GB", { dateStyle: "short", timeStyle: "short", hourCycle: "h23" });
}
export default function Deploy({ options }) {
const { service, label, apiBase = "/deploy-api" } = options ?? {};
const bp = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// SWR middleware already prepends bp, so statusUrl must NOT include it
const statusUrl = service ? `${apiBase}/status/${service}` : null;
const { data, mutate } = useSWR(statusUrl, {
refreshInterval: (d) => (d?.status === "running" ? 2000 : 10000),
});
const [triggering, setTriggering] = useState(false);
const status = data?.status ?? "idle";
const lastRun = data?.last_run ? formatTime(data.last_run) : null;
const isRunning = status === "running";
const handleDeploy = async () => {
if (isRunning || triggering) return;
setTriggering(true);
try {
await fetch(`${bp}${apiBase}/deploy/${service}`, { method: "POST" });
await mutate();
} finally {
setTriggering(false);
}
};
if (!service) {
return (
<div className="flex flex-col items-center text-theme-500 text-xs p-2">
<span>No service configured</span>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center p-2 gap-1 w-full">
<div className={`text-xs font-semibold ${STATUS_COLORS[status] ?? STATUS_COLORS.idle}`}>
{isRunning ? (
<span className="animate-pulse">{STATUS_LABELS.running}</span>
) : (
STATUS_LABELS[status] ?? status
)}
</div>
{lastRun && (
<div className="text-theme-500 dark:text-theme-400 text-xs opacity-75">{lastRun}</div>
)}
<button
type="button"
onClick={handleDeploy}
disabled={isRunning || triggering}
className={[
"mt-1 px-3 py-1 rounded text-xs font-medium transition-colors",
isRunning || triggering
? "bg-theme-300 dark:bg-theme-600 text-theme-500 dark:text-theme-400 cursor-not-allowed"
: "bg-theme-500 hover:bg-theme-600 dark:bg-theme-600 dark:hover:bg-theme-500 text-white cursor-pointer",
].join(" ")}
>
{isRunning ? "Running..." : `Deploy ${label ?? service}`}
</button>
</div>
);
}

View file

@ -106,7 +106,7 @@ export default function Search({ options }) {
query.trim().length > 0 &&
query.trim() !== searchSuggestions[0]
) {
fetch(`/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/search/searchSuggestion?query=${encodeURIComponent(query)}&providerName=${selectedProvider.name}`, {
signal: abortController.signal,
})
.then(async (searchSuggestionResult) => {

View file

@ -15,6 +15,7 @@ const widgetMappings = {
longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")),
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
stocks: dynamic(() => import("components/widgets/stocks/stocks")),
deploy: dynamic(() => import("components/widgets/deploy/deploy"), { ssr: false }),
};
export default function Widget({ widget, style }) {

View file

@ -67,11 +67,18 @@ const tailwindSafelist = [
"2xl:h-0 2xl:h-1 2xl:h-2 2xl:h-3 2xl:h-4 2xl:h-5 2xl:h-6 2xl:h-7 2xl:h-8 2xl:h-9 2xl:h-10 2xl:h-11 2xl:h-12 2xl:h-13 2xl:h-14 2xl:h-15 2xl:h-16 2xl:h-17 2xl:h-18 2xl:h-19 2xl:h-20 2xl:h-21 2xl:h-22 2xl:h-23 2xl:h-24 2xl:h-25 2xl:h-26 2xl:h-27 2xl:h-28 2xl:h-29 2xl:h-30 2xl:h-31 2xl:h-32 2xl:h-33 2xl:h-34 2xl:h-35 2xl:h-36 2xl:h-37 2xl:h-38 2xl:h-39 2xl:h-40 2xl:h-41 2xl:h-42 2xl:h-43 2xl:h-44 2xl:h-45 2xl:h-46 2xl:h-47 2xl:h-48 2xl:h-49 2xl:h-50 2xl:h-51 2xl:h-52 2xl:h-53 2xl:h-54 2xl:h-55 2xl:h-56 2xl:h-57 2xl:h-58 2xl:h-59 2xl:h-60 2xl:h-61 2xl:h-62 2xl:h-63 2xl:h-64 2xl:h-65 2xl:h-66 2xl:h-67 2xl:h-68 2xl:h-69 2xl:h-70 2xl:h-71 2xl:h-72 2xl:h-73 2xl:h-74 2xl:h-75 2xl:h-76 2xl:h-77 2xl:h-78 2xl:h-79 2xl:h-80 2xl:h-81 2xl:h-82 2xl:h-83 2xl:h-84 2xl:h-85 2xl:h-86 2xl:h-87 2xl:h-88 2xl:h-89 2xl:h-90 2xl:h-91 2xl:h-92 2xl:h-93 2xl:h-94 2xl:h-95 2xl:h-96",
];
const basePathMiddleware = (useSWRNext) => (key, fetcher, config) => {
const bp = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const adjustedKey = bp && typeof key === "string" ? `${bp}${key}` : key;
return useSWRNext(adjustedKey, fetcher, config);
};
function MyApp({ Component, pageProps }) {
return (
<SWRConfig
value={{
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
use: [basePathMiddleware],
}}
>
<Head>

View file

@ -6,8 +6,8 @@ export default function Document() {
<Head>
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/site.webmanifest?v=4" crossOrigin="use-credentials" />
<link rel="preload" href="/api/config/custom.css" as="style" />
<link rel="stylesheet" href="/api/config/custom.css" /> {/* eslint-disable-line @next/next/no-css-tags */}
<link rel="preload" href={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/config/custom.css`} as="style" />
<link rel="stylesheet" href={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/config/custom.css`} /> {/* eslint-disable-line @next/next/no-css-tags */}
</Head>
<body>
<Main />

View file

@ -63,14 +63,15 @@ export async function getStaticProps() {
const widgets = await widgetsResponse();
const language = normalizeLanguage(settings.language);
const bp = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
return {
props: {
initialSettings: settings,
fallback: {
"/api/services": services,
"/api/bookmarks": bookmarks,
"/api/widgets": widgets,
"/api/hash": false,
[`${bp}/api/services`]: services,
[`${bp}/api/bookmarks`]: bookmarks,
[`${bp}/api/widgets`]: widgets,
[`${bp}/api/hash`]: false,
},
...(await serverSideTranslations(language)),
},
@ -83,10 +84,10 @@ export async function getStaticProps() {
props: {
initialSettings: {},
fallback: {
"/api/services": [],
"/api/bookmarks": [],
"/api/widgets": [],
"/api/hash": false,
[`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/services`]: [],
[`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/bookmarks`]: [],
[`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/widgets`]: [],
[`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/hash`]: false,
},
...(await serverSideTranslations("en")),
},
@ -120,7 +121,7 @@ function Index({ initialSettings, fallback }) {
setStale(true);
localStorage.setItem("hash", hashData.hash);
fetch("/api/revalidate").then((res) => {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/revalidate`).then((res) => {
if (res.ok) {
window.location.reload();
}
@ -434,7 +435,7 @@ function Home({ initialSettings }) {
<meta name="color-scheme" content="dark light"></meta>
</Head>
<Script src="/api/config/custom.js" />
<Script src={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/config/custom.js`} />
<div
className={classNames(

View file

@ -435,6 +435,11 @@ export function cleanServiceGroups(groups) {
// grafana
alerts,
// deploy
service: deployService,
label: deployLabel,
apiBase: deployApiBase,
} = widgetData;
let fieldsList = fields;
@ -685,6 +690,11 @@ export function cleanServiceGroups(groups) {
if (type === "grafana") {
if (alerts) widget.alerts = alerts;
}
if (type === "deploy") {
if (deployService) widget.service = deployService;
if (deployLabel) widget.label = deployLabel;
if (deployApiBase) widget.apiBase = deployApiBase;
}
if (type === "unraid") {
if (pool1) widget.pool1 = pool1;
if (pool2) widget.pool2 = pool2;

View file

@ -29,7 +29,7 @@ export function formatProxyUrl(widget, endpoint, queryParams) {
if (queryParams) {
params.append("query", JSON.stringify(queryParams));
}
return `/api/services/proxy?${params.toString()}`;
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/services/proxy?${params.toString()}`;
}
export function asJson(data) {

View file

@ -26,6 +26,7 @@ const components = {
iframe: dynamic(() => import("./iframe/component")),
customapi: dynamic(() => import("./customapi/component")),
deluge: dynamic(() => import("./deluge/component")),
deploy: dynamic(() => import("./deploy/component")),
develancacheui: dynamic(() => import("./develancacheui/component")),
diskstation: dynamic(() => import("./diskstation/component")),
dispatcharr: dynamic(() => import("./dispatcharr/component")),

View file

@ -0,0 +1,78 @@
import { useState } from "react";
import useSWR from "swr";
const STATUS_COLORS = {
idle: "text-theme-500 dark:text-theme-400",
running: "text-blue-500 dark:text-blue-400",
success: "text-emerald-500 dark:text-emerald-400",
failed: "text-red-500 dark:text-red-400",
};
const STATUS_LABELS = {
idle: "Never deployed",
running: "Deploying...",
success: "Deployed",
failed: "Failed",
};
function formatTime(iso) {
if (!iso) return null;
const d = new Date(iso);
return d.toLocaleString("en-GB", { dateStyle: "short", timeStyle: "short", hourCycle: "h23" });
}
export default function DeployComponent({ service }) {
const { service: svcName, label, apiBase = "/deploy-api" } = service?.widget ?? {};
const bp = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// SWR middleware already prepends bp, so statusUrl must NOT include it
const statusUrl = svcName ? `${apiBase}/status/${svcName}` : null;
const { data, mutate } = useSWR(statusUrl, {
refreshInterval: (d) => (d?.status === "running" ? 2000 : 10000),
});
const [triggering, setTriggering] = useState(false);
const status = data?.status ?? "idle";
const lastRun = data?.last_run ? formatTime(data.last_run) : null;
const isRunning = status === "running";
const handleDeploy = async () => {
if (isRunning || triggering) return;
setTriggering(true);
try {
await fetch(`${bp}${apiBase}/deploy/${svcName}`, { method: "POST" });
await mutate();
} finally {
setTriggering(false);
}
};
if (!svcName) return null;
return (
<div className="flex flex-row items-center justify-between px-2 py-1 gap-2 w-full">
<div className="flex flex-col">
<span className={`text-xs font-semibold ${STATUS_COLORS[status] ?? STATUS_COLORS.idle}`}>
{isRunning ? <span className="animate-pulse">{STATUS_LABELS.running}</span> : (STATUS_LABELS[status] ?? status)}
</span>
{lastRun && (
<span className="text-theme-500 dark:text-theme-400 text-xs opacity-60">{lastRun}</span>
)}
</div>
<button
type="button"
onClick={handleDeploy}
disabled={isRunning || triggering}
className={[
"px-3 py-1 rounded text-xs font-medium transition-colors shrink-0",
isRunning || triggering
? "bg-theme-300 dark:bg-theme-600 text-theme-500 dark:text-theme-400 cursor-not-allowed"
: "bg-theme-500 hover:bg-theme-600 dark:bg-theme-600 dark:hover:bg-theme-500 text-white cursor-pointer",
].join(" ")}
>
{isRunning ? "Running..." : `Deploy ${label ?? svcName}`}
</button>
</div>
);
}