diff --git a/backend/service_clients/local_exec.go b/backend/service_clients/local_exec.go new file mode 100644 index 000000000..b2fd08a65 --- /dev/null +++ b/backend/service_clients/local_exec.go @@ -0,0 +1,113 @@ +package service_clients + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" +) + +type LocalExecJobClient struct {} + +// TriggerProjectsRefreshLocal starts a local binary with the required environment. +// Binary path is taken from PROJECTS_REFRESH_BIN (fallback: ./projects-refresh-service). +// It does NOT wait for completion; it returns as soon as the process starts successfully. +func (f LocalExecJobClient) TriggerProjectsRefreshService( + cloneUrl, branch, githubToken, repoFullName, orgId string, +) (*BackgroundJobTriggerResponse, error) { + + slog.Debug("starting local projects-refresh-service job") + + // Resolve binary path from env or default. + bin := os.Getenv("PROJECTS_REFRESH_BIN") + if bin == "" { + bin = "../../background/projects-refresh-service/projects_refesh_main" + } + + // Optional: working directory (set via env if you want), otherwise current dir. + workingDir := os.Getenv("PROJECTS_REFRESH_WORKDIR") + if workingDir == "" { + wd, _ := os.Getwd() + workingDir = wd + } + + // Build environment for the child process. + // Keep existing env and append required vars. + env := append(os.Environ(), + "DIGGER_GITHUB_REPO_CLONE_URL="+cloneUrl, + "DIGGER_GITHUB_REPO_CLONE_BRANCH="+branch, + "DIGGER_GITHUB_TOKEN="+githubToken, + "DIGGER_REPO_FULL_NAME="+repoFullName, + "DIGGER_ORG_ID="+orgId, + "DATABASE_URL="+os.Getenv("DATABASE_URL"), + ) + + // Optional: add any tuning flags you previously used in the container world. + // env = append(env, "GODEBUG=off", "GOFIPS140=off") + + // If your binary needs args, add them here. Empty for now. + cmd := exec.Command(bin) + cmd.Dir = workingDir + cmd.Env = env + + // Pipe stdout/stderr to slog for observability. + stdout, err := cmd.StdoutPipe() + if err != nil { + slog.Error("allocating stdout pipe failed", "error", err) + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + slog.Error("allocating stderr pipe failed", "error", err) + return nil, err + } + + // Start process. + if err := cmd.Start(); err != nil { + slog.Error("failed to start local job", "binary", bin, "dir", workingDir, "error", err) + return nil, err + } + + // Stream logs in background goroutines tied to a short-lived context so we don't leak. + ctx, cancel := context.WithCancel(context.Background()) + go pipeToSlog(ctx, stdout, slog.LevelInfo, "projects-refresh") + go pipeToSlog(ctx, stderr, slog.LevelError, "projects-refresh") + + // Optionally, you can watch for process exit in a goroutine if you want to log completion. + go func() { + defer cancel() + waitErr := cmd.Wait() + if waitErr != nil { + slog.Error("local job exited with error", "pid", cmd.Process.Pid, "error", waitErr) + return + } + slog.Info("local job completed", "pid", cmd.Process.Pid) + }() + + slog.Debug("triggered local projects refresh", "pid", cmd.Process.Pid, "binary", bin, "workdir", workingDir) + + return &BackgroundJobTriggerResponse{ID: fmt.Sprintf("%d", cmd.Process.Pid)}, nil +} + +// pipeToSlog streams a reader line-by-line into slog at the given level. +func pipeToSlog(ctx context.Context, r io.Reader, level slog.Level, comp string) { + br := bufio.NewScanner(r) + // Increase the Scanner buffer in case the tool emits long lines. + buf := make([]byte, 0, 64*1024) + br.Buffer(buf, 10*1024*1024) + + for br.Scan() { + select { + case <-ctx.Done(): + return + default: + slog.Log(context.Background(), level, br.Text(), "component", comp) + } + } + if err := br.Err(); err != nil { + slog.Error("log stream error", "component", comp, "error", err) + } +} diff --git a/backend/service_clients/router.go b/backend/service_clients/router.go index c5fd15861..85011554f 100644 --- a/backend/service_clients/router.go +++ b/backend/service_clients/router.go @@ -7,16 +7,20 @@ import ( func GetBackgroundJobsClient() (BackgroundJobsClient, error) { clientType := os.Getenv("BACKGROUND_JOBS_CLIENT_TYPE") - if clientType == "k8s" { - clientSet, err := newInClusterClient() - if err != nil { - return nil, fmt.Errorf("error creating k8s client: %v", err) - } - return K8sJobClient{ - clientset: clientSet, - namespace: "opentaco", - }, nil - } else { - return FlyIOMachineJobClient{}, nil + switch clientType { + case "k8s": + clientSet, err := newInClusterClient() + if err != nil { + return nil, fmt.Errorf("error creating k8s client: %v", err) + } + return K8sJobClient{ + clientset: clientSet, + namespace: "opentaco", + }, nil + case "flyio": + return FlyIOMachineJobClient{}, nil + case "local-exec": + return LocalExecJobClient{}, nil } + return FlyIOMachineJobClient{}, nil } diff --git a/background/projects-refresh-service/.gitignore b/background/projects-refresh-service/.gitignore index 88d050b19..0973ffec2 100644 --- a/background/projects-refresh-service/.gitignore +++ b/background/projects-refresh-service/.gitignore @@ -1 +1,2 @@ -main \ No newline at end of file +main +/projects_refesh_main diff --git a/ui/src/api/orchestrator_serverFunctions.ts b/ui/src/api/orchestrator_serverFunctions.ts index cf2e9da59..d093a37c9 100644 --- a/ui/src/api/orchestrator_serverFunctions.ts +++ b/ui/src/api/orchestrator_serverFunctions.ts @@ -62,7 +62,7 @@ export const getProjectFn = createServerFn({method: 'GET'}) .inputValidator((data : {projectId: string, organisationId: string, userId: string}) => data) .handler(async ({ data }) => { const project : any = await fetchProject(data.projectId, data.organisationId, data.userId) - return project.result + return project }) diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx index 0dbeefcf5..153234854 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx @@ -47,6 +47,7 @@ export const Route = createFileRoute( loader: async ({ context, params: {projectid} }) => { const { user, organisationId } = context; const project = await getProjectFn({data: {projectId: projectid, organisationId, userId: user?.id || ''}}) + return { project } } }) diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx index 035b5019a..6dc77cead 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.index.tsx @@ -91,54 +91,69 @@ function RouteComponent() { List of projects detected accross all repositories. Each project represents a statefile and is loaded from digger.yml in the root of the repository. - - - - Repository - Name - Directory - Drift enabled - Drift status - Details - - - - {projectList.map((project: Project) => { - return ( - - - - {project.repo_full_name} - - - - {project.name} - - {project.directory} - - - handleDriftToggle(project)} - className="h-4 w-4 rounded border-gray-300" - /> - - - {project.drift_status} - - - - - - ) - })} - -
+ {projectList.length === 0 ? ( +
+

No Projects Found

+

+ Projects represent entries loaded from your digger.yml. + They will appear here when you connect repositories that contain a valid digger.yml. +

+ +
+ ) : ( + + + + Repository + Name + Directory + Drift enabled + Drift status + Details + + + + {projectList.map((project: Project) => { + return ( + + + + {project.repo_full_name} + + + + {project.name} + + {project.directory} + + + handleDriftToggle(project)} + className="h-4 w-4 rounded border-gray-300" + /> + + + {project.drift_status} + + + + + + ) + })} + +
+ )}
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx index ecd9267e0..ff2d47915 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx @@ -20,10 +20,12 @@ import { DialogTrigger, } from "@/components/ui/dialog" -import { useState } from "react" +import { useEffect, useState } from "react" import UnitCreateForm from "@/components/UnitCreateForm" import { listUnitsFn } from '@/api/statesman_serverFunctions' import { PageLoading } from '@/components/LoadingSkeleton' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { ChevronDown, X } from 'lucide-react' export const Route = createFileRoute( '/_authenticated/_dashboard/dashboard/units/', @@ -53,6 +55,57 @@ function formatBytes(bytes: number) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } +function LoomBanner() { + const [dismissed, setDismissed] = useState(false) + const [open, setOpen] = useState(false) + + useEffect(() => { + if (typeof window === 'undefined') return + window.localStorage.setItem('units_loom_open', String(open)) + }, [open]) + + const handleDismiss = () => { + setDismissed(true) + try { + window.localStorage.setItem('units_loom_dismissed', 'true') + } catch {} + } + + if (dismissed) return null + + return ( +
+ +
+
+ + Watch a quick walkthrough (2 min) + + + +
+ +
+