diff --git a/.env.example b/.env.example index 7d88451..83169c0 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ # 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 + 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 { 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/services/marketplace.ts b/src/services/marketplace.ts new file mode 100644 index 0000000..8acb5a8 --- /dev/null +++ b/src/services/marketplace.ts @@ -0,0 +1,49 @@ +import { apiClient } from "./api"; + +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 }) => + apiClient.get>("/marketplace/templates", { params }), + search: (q: string) => apiClient.get>("/marketplace/search", { params: { q } }), + categories: () => apiClient.get<{ data: MarketplaceCategory[] }>("/marketplace/categories"), + download: (slug: string) => apiClient.get(`/marketplace/templates/${slug}/download`), +}; diff --git a/src/stores/plugins.ts b/src/stores/plugins.ts index e41c8dd..e0debb4 100644 --- a/src/stores/plugins.ts +++ b/src/stores/plugins.ts @@ -12,6 +12,7 @@ export interface Plugin { category: string; enabled: boolean; capabilities?: string[]; + config_schema?: Record; widget?: { enabled: boolean; position: string; 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; diff --git a/src/views/MarketplaceView.vue b/src/views/MarketplaceView.vue index 272467b..784dbce 100644 --- a/src/views/MarketplaceView.vue +++ b/src/views/MarketplaceView.vue @@ -1,272 +1,285 @@ diff --git a/src/views/PluginsView.vue b/src/views/PluginsView.vue index fd5cc27..9e17c30 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..." > - - - +