From 32e438875075fc09c0c02574659dbc20c8fadf6d Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 30 Jun 2026 01:31:41 +0100 Subject: [PATCH 1/6] feat(ui): Add deployment rebuild options and primary-service control Deployment and service actions can now request a forced recreate, a no-cache rebuild, or a fresh image pull, so updated environment variables and images take effect without using the terminal. A deployment's primary service can be set from the services list and is shown on each service. --- src/composables/useDeploymentJob.test.ts | 2 +- src/composables/useDeploymentJob.ts | 16 +++--- src/composables/useServiceJobs.test.ts | 2 +- src/composables/useServiceJobs.ts | 6 +- src/services/api.ts | 23 ++++++-- src/types/index.ts | 2 + src/views/DeploymentDetailView.vue | 70 +++++++++++++++++++++++- 7 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/composables/useDeploymentJob.test.ts b/src/composables/useDeploymentJob.test.ts index 2236acc..c1a406a 100644 --- a/src/composables/useDeploymentJob.test.ts +++ b/src/composables/useDeploymentJob.test.ts @@ -50,7 +50,7 @@ describe("useDeploymentJob", () => { await run("start", "app"); await flushPromises(); - expect(start).toHaveBeenCalledWith("app"); + expect(start).toHaveBeenCalledWith("app", undefined); expect(getJob).toHaveBeenCalledWith("app", "job-1"); expect(state.isRunning).toBe(false); expect(state.isSuccess).toBe(true); diff --git a/src/composables/useDeploymentJob.ts b/src/composables/useDeploymentJob.ts index 1dc95f0..b58eb54 100644 --- a/src/composables/useDeploymentJob.ts +++ b/src/composables/useDeploymentJob.ts @@ -1,5 +1,5 @@ import { reactive, onScopeDispose } from "vue"; -import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api"; +import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus, type ActionOptions } from "@/services/api"; export type DeploymentOperation = "start" | "stop" | "restart" | "rebuild"; @@ -99,23 +99,23 @@ export function useDeploymentJob(onSettled?: (state: DeploymentJobState) => void onSettled?.(state); } - async function enqueue(operation: DeploymentOperation, name: string) { + async function enqueue(operation: DeploymentOperation, name: string, opts?: ActionOptions) { switch (operation) { case "start": - return deploymentsApi.start(name); + return deploymentsApi.start(name, opts); case "stop": - return deploymentsApi.stop(name); + return deploymentsApi.stop(name, opts); case "restart": - return deploymentsApi.restart(name); + return deploymentsApi.restart(name, opts); case "rebuild": - return deploymentsApi.rebuild(name); + return deploymentsApi.rebuild(name, opts); } } - async function run(operation: DeploymentOperation, name: string) { + async function run(operation: DeploymentOperation, name: string, opts?: ActionOptions) { begin(operation, name); try { - const res = await enqueue(operation, name); + const res = await enqueue(operation, name, opts); attach(res.data.job_id); } catch (e: any) { const activeId = e?.response?.data?.active_job_id; diff --git a/src/composables/useServiceJobs.test.ts b/src/composables/useServiceJobs.test.ts index 9e4ad37..d847f7e 100644 --- a/src/composables/useServiceJobs.test.ts +++ b/src/composables/useServiceJobs.test.ts @@ -46,7 +46,7 @@ describe("useServiceJobs", () => { await run("web", "rebuild"); await flushPromises(); - expect(serviceActionJob).toHaveBeenCalledWith("app", "web", "rebuild"); + expect(serviceActionJob).toHaveBeenCalledWith("app", "web", "rebuild", undefined); expect(getJob).toHaveBeenCalledWith("app", "job-1"); expect(states.web.action).toBe("rebuild"); expect(states.web.isRunning).toBe(false); diff --git a/src/composables/useServiceJobs.ts b/src/composables/useServiceJobs.ts index 09939e1..728e59b 100644 --- a/src/composables/useServiceJobs.ts +++ b/src/composables/useServiceJobs.ts @@ -1,5 +1,5 @@ import { reactive, onScopeDispose } from "vue"; -import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api"; +import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus, type ActionOptions } from "@/services/api"; export type ServiceAction = "start" | "stop" | "restart" | "rebuild" | "pull"; @@ -61,7 +61,7 @@ export function useServiceJobs(getDeployment: () => string, onSettled?: (s: Serv onSettled?.(s); } - async function run(service: string, action: ServiceAction) { + async function run(service: string, action: ServiceAction, opts?: ActionOptions) { const name = getDeployment(); teardown(service); controllers[service] = { socket: null, timer: null, settled: false }; @@ -72,7 +72,7 @@ export function useServiceJobs(getDeployment: () => string, onSettled?: (s: Serv s.isSuccess = null; try { - const res = await deploymentsApi.serviceActionJob(name, service, action); + const res = await deploymentsApi.serviceActionJob(name, service, action, opts); openStream(service, res.data.job_id); } catch (e: any) { const activeId = e?.response?.data?.active_job_id; diff --git a/src/services/api.ts b/src/services/api.ts index 9e2e520..12225c2 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -56,6 +56,7 @@ apiClient.interceptors.response.use( export interface ServiceMetadata { name: string; type: string; + primary_service?: string; networking: { expose: boolean; domain: string; @@ -94,6 +95,14 @@ const withPlanQuery = (url: string, opts?: PlanOpts) => { export type DeploymentActionStatus = "pending" | "running" | "succeeded" | "failed"; +// ActionOptions are effective-apply flags so updated env vars and images take effect +// instead of a plain start/restart reusing cached config and images. +export interface ActionOptions { + force_recreate?: boolean; + no_cache?: boolean; + fresh_pull?: boolean; +} + export interface ActionJobResponse { job_id: string; deployment: string; @@ -159,14 +168,16 @@ export const deploymentsApi = { apiClient.post<{ message: string; name: string; service: string; output: string }>( withPlanQuery(`/deployments/${name}/services/${service}/${action}`, opts), ), - start: (name: string) => apiClient.post(`/deployments/${name}/start`), - stop: (name: string) => apiClient.post(`/deployments/${name}/stop`), - restart: (name: string) => apiClient.post(`/deployments/${name}/restart`), - rebuild: (name: string) => apiClient.post(`/deployments/${name}/rebuild`), + start: (name: string, opts?: ActionOptions) => apiClient.post(`/deployments/${name}/start`, opts), + stop: (name: string, opts?: ActionOptions) => apiClient.post(`/deployments/${name}/stop`, opts), + restart: (name: string, opts?: ActionOptions) => + apiClient.post(`/deployments/${name}/restart`, opts), + rebuild: (name: string, opts?: ActionOptions) => + apiClient.post(`/deployments/${name}/rebuild`, opts), getJob: (name: string, jobId: string) => apiClient.get(`/deployments/${name}/jobs/${jobId}`), getActiveJob: (name: string) => apiClient.get(`/deployments/${name}/jobs/active`), - serviceActionJob: (name: string, service: string, action: string) => - apiClient.post(`/deployments/${name}/services/${service}/job`, { action }), + serviceActionJob: (name: string, service: string, action: string, opts?: ActionOptions) => + apiClient.post(`/deployments/${name}/services/${service}/job`, { action, ...opts }), pullImage: (name: string, onlyLatest: boolean = false) => apiClient.post<{ message: string; name: string; output: string }>(`/deployments/${name}/pull`, { only_latest: onlyLatest, diff --git a/src/types/index.ts b/src/types/index.ts index e4c27a6..2893eae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,12 +16,14 @@ export interface Service { health?: string; ports?: string[]; networks?: string[]; + is_primary?: boolean; created_at: string; } export interface ServiceMetadata { name: string; type: string; + primary_service?: string; networking: NetworkingConfig; ssl: SSLConfig; healthcheck: HealthCheckConfig; diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index f8130ff..99c7b06 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -330,6 +330,10 @@ >
{{ service.name }} + + + Primary + {{ service.status }}
@@ -343,6 +347,16 @@
+ @@ -1438,6 +1452,20 @@ changes. A simple restart does not use newly pulled images.
+
+ + + +
Or rebuild a single service
@@ -1773,6 +1801,7 @@ import { securityApi, credentialsApi, type EnvVar, + type ActionOptions, } from "@/services/api"; import { useNotificationsStore } from "@/stores/notifications"; import { useAuthStore } from "@/stores/auth"; @@ -2496,9 +2525,9 @@ const fetchStats = async () => { } }; -const handleOperation = async (operation: string, onlyLatest: boolean = false) => { +const handleOperation = async (operation: string, onlyLatest: boolean = false, opts?: ActionOptions) => { if (operation !== "pull") { - deploymentJob.run(operation as DeploymentOperation, route.params.name as string); + deploymentJob.run(operation as DeploymentOperation, route.params.name as string, opts); return; } @@ -2543,9 +2572,11 @@ const executePull = async (onlyLatest: boolean) => { await handleOperation("pull", onlyLatest); }; +const rebuildOptions = reactive({ force_recreate: true, no_cache: false, fresh_pull: false }); + const executeRebuild = async () => { showRebuildModal.value = false; - await handleOperation("rebuild"); + await handleOperation("rebuild", false, { ...rebuildOptions }); }; const executeAction = async (action: { id: string; name: string }) => { @@ -2934,6 +2965,23 @@ const openDomainSettings = () => { showDomainSettingsModal.value = true; }; +const savingPrimaryService = ref(false); +const togglePrimaryService = (service: { name: string; is_primary?: boolean }) => + setPrimaryService(service.is_primary ? "" : service.name); + +const setPrimaryService = async (value: string) => { + savingPrimaryService.value = true; + try { + await deploymentsApi.updateMetadata(route.params.name as string, { primary_service: value }); + notifications.success("Saved", value ? `Primary service set to ${value}` : "Primary service reset to auto-detect"); + await fetchDeployment(); + } catch (err: any) { + notifications.error("Save Failed", err.response?.data?.error || err.message); + } finally { + savingPrimaryService.value = false; + } +}; + const saveDomainSettings = async () => { savingDomainSettings.value = true; try { @@ -3573,6 +3621,22 @@ onUnmounted(() => { font-weight: var(--font-medium); } +.service-primary-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); + font-weight: var(--font-medium); + color: var(--primary); + background: var(--primary-soft, rgba(99, 102, 241, 0.12)); +} + +.action-btn--primary { + color: var(--primary); +} + /* Databases List */ .databases-list { display: flex; From 00044c02145fe939cb600e485a33a4778abb678f Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 30 Jun 2026 01:31:52 +0100 Subject: [PATCH 2/6] fix(ui): Fix dark mode on the database search inputs The database search fields rendered with a white background in dark mode because they set a border but no surface or text colour. They now follow the theme tokens like the rest of the inputs. --- src/components/database/DatabaseDetails.vue | 6 ++++++ src/components/database/DatabaseSidebar.vue | 6 ++++++ src/components/database/QueryHistory.vue | 6 ++++++ src/components/database/TableList.vue | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/src/components/database/DatabaseDetails.vue b/src/components/database/DatabaseDetails.vue index cbbc481..f7aef77 100644 --- a/src/components/database/DatabaseDetails.vue +++ b/src/components/database/DatabaseDetails.vue @@ -544,6 +544,12 @@ const filteredAllDatabases = computed(() => { border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--text-sm); + background: var(--surface); + color: var(--text); +} + +.users-search .search-input::placeholder { + color: var(--text-subtle); } .users-search .search-input:focus { diff --git a/src/components/database/DatabaseSidebar.vue b/src/components/database/DatabaseSidebar.vue index 2afedc6..4271984 100644 --- a/src/components/database/DatabaseSidebar.vue +++ b/src/components/database/DatabaseSidebar.vue @@ -201,6 +201,12 @@ function clearSearch() { border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--text-sm); + background: var(--surface); + color: var(--text); +} + +.search-input::placeholder { + color: var(--text-subtle); } .search-input:focus { diff --git a/src/components/database/QueryHistory.vue b/src/components/database/QueryHistory.vue index 720f810..12a4f60 100644 --- a/src/components/database/QueryHistory.vue +++ b/src/components/database/QueryHistory.vue @@ -245,6 +245,12 @@ onMounted(() => { border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--text-sm); + background: var(--surface); + color: var(--text); +} + +.search-input::placeholder { + color: var(--text-subtle); } .search-input:focus { diff --git a/src/components/database/TableList.vue b/src/components/database/TableList.vue index 06208eb..d926ec0 100644 --- a/src/components/database/TableList.vue +++ b/src/components/database/TableList.vue @@ -182,6 +182,12 @@ function formatNumber(num: number): string { border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--text-sm); + background: var(--surface); + color: var(--text); +} + +.search-input::placeholder { + color: var(--text-subtle); } .search-input:focus { From 775a7d87817d0d27ba1900bd7a0f4f90a134d067 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 30 Jun 2026 01:32:17 +0100 Subject: [PATCH 3/6] feat(ui): Replace marketplace mock data with the live catalog The marketplace listed hardcoded sample apps, one of them falsely shown as installed. It now loads the real catalog from the marketplace service, marks an app installed only when a matching deployment exists, and installs by deploying the chosen template. The app cards are smaller and consistent between the marketplace and the installed-apps page, with loading, empty, and error states, and the non-functional configure and uninstall actions are removed. --- .env.example | 3 + src/services/marketplace.ts | 55 +++ src/views/MarketplaceView.vue | 848 +++++++++++++++++++--------------- src/views/PluginsView.vue | 587 +++++------------------ 4 files changed, 654 insertions(+), 839 deletions(-) create mode 100644 src/services/marketplace.ts diff --git a/.env.example b/.env.example index 7d88451..3419bba 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ # API base URL — defaults to "/api" (works with nginx proxy in production) # For local development, point to the agent directly: VITE_API_URL=http://localhost:8090/api + +# Marketplace catalog API (public). Defaults to the hosted service when unset. +VITE_MARKETPLACE_URL=https://api.flatrun.dev/v1 diff --git a/src/services/marketplace.ts b/src/services/marketplace.ts new file mode 100644 index 0000000..21842d8 --- /dev/null +++ b/src/services/marketplace.ts @@ -0,0 +1,55 @@ +import axios from "axios"; + +// The marketplace is a separate public service from the agent API. Browsing needs no auth. +const marketplaceClient = axios.create({ + baseURL: import.meta.env.VITE_MARKETPLACE_URL || "https://api.flatrun.dev/v1", + timeout: 15000, +}); + +export interface MarketplaceCategory { + slug: string; + name: string; + description: string | null; + icon: string | null; + color: string | null; + templates_count?: number; +} + +export interface MarketplaceTemplate { + slug: string; + name: string; + description: string; + icon: string | null; + logo: string | null; + category?: MarketplaceCategory; + source_type: string; + latest_version: string | null; + downloads_count: number; + stars_count: number; + is_verified: boolean; + is_featured: boolean; + is_official: boolean; + updated_at: string; +} + +// The download endpoint returns a bare agent-format payload (no data wrapper). +export interface AgentTemplatePayload { + id: string; + name: string; + description: string; + content: string; + version: string; +} + +interface Paginated { + data: T[]; + meta: { current_page: number; last_page: number; per_page: number; total: number }; +} + +export const marketplaceApi = { + templates: (params?: { category?: string; featured?: boolean; per_page?: number; page?: number }) => + marketplaceClient.get>("/templates", { params }), + search: (q: string) => marketplaceClient.get>("/search", { params: { q } }), + categories: () => marketplaceClient.get<{ data: MarketplaceCategory[] }>("/categories"), + download: (slug: string) => marketplaceClient.get(`/templates/${slug}/download`), +}; diff --git a/src/views/MarketplaceView.vue b/src/views/MarketplaceView.vue index 272467b..2c6c93d 100644 --- a/src/views/MarketplaceView.vue +++ b/src/views/MarketplaceView.vue @@ -1,272 +1,283 @@ diff --git a/src/views/PluginsView.vue b/src/views/PluginsView.vue index fd5cc27..2248147 100644 --- a/src/views/PluginsView.vue +++ b/src/views/PluginsView.vue @@ -12,35 +12,33 @@ default-view-mode="grid" empty-icon="pi pi-th-large" empty-title="No Apps Installed" - empty-text="Extend Flatrun's functionality by installing apps from the marketplace." + empty-text="Browse the marketplace to deploy apps, or drop an app folder into .flatrun/plugins/." loading-text="Loading apps..." > - - - + - - - -
From 94ad6d77ab83a8e8e7157e0b9f4c9481dc98f841 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 30 Jun 2026 15:27:51 +0100 Subject: [PATCH 6/6] docs(ui): Drop unnecessary comments --- src/services/marketplace.ts | 4 ---- src/views/MarketplaceView.vue | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/services/marketplace.ts b/src/services/marketplace.ts index 9cf70c9..8acb5a8 100644 --- a/src/services/marketplace.ts +++ b/src/services/marketplace.ts @@ -1,9 +1,5 @@ import { apiClient } from "./api"; -// The marketplace restricts CORS to its own origin, so the browser cannot call it directly. -// The agent proxies it under /marketplace (upstream configurable via FLATRUN_MARKETPLACE_API), -// and the shared apiClient targets that same-origin path. - export interface MarketplaceCategory { slug: string; name: string; diff --git a/src/views/MarketplaceView.vue b/src/views/MarketplaceView.vue index bc390b4..784dbce 100644 --- a/src/views/MarketplaceView.vue +++ b/src/views/MarketplaceView.vue @@ -177,8 +177,7 @@ async function load() { onMounted(load); -// A template is installed when a deployment created from it exists. Installing defaults the -// deployment name to the template slug, so that is the match we check. +// Matched by slug, since installing defaults the deployment name to the slug. function isInstalled(app: MarketplaceTemplate): boolean { return deploymentsStore.deployments.some((d) => d.name === app.slug); }