Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
VITE_API_URL=http://localhost:8080
# FlatRun UI Environment Variables
# Copy this file to .env.local and adjust values for your environment.
# .env.local is gitignored and will not be committed.

# 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
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci
run: npm install

- name: Run ESLint
run: npm run lint
Expand All @@ -37,11 +37,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci
run: npm install

- name: Run type check
run: npm run type-check
Expand All @@ -55,11 +55,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci
run: npm install

- name: Run tests
run: npm run test:run
Expand All @@ -74,11 +74,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
run: npm ci
run: npm install

- name: Build
run: npm run build
Expand Down
48 changes: 47 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
<template>
<div id="app">
<ToastNotifications />
<router-view />
<div v-if="!ready" class="app-loading">
<Logo variant="icon" size="lg" />
</div>
<router-view v-else />
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import ToastNotifications from "@/components/ToastNotifications.vue";
import Logo from "@/components/base/Logo.vue";
import { useSetupStore } from "@/stores/setup";
import { useRouter } from "vue-router";

const ready = ref(false);
const router = useRouter();
const setup = useSetupStore();

onMounted(async () => {
try {
await setup.checkSetupStatus();
if (setup.initialized === false) {
router.replace("/setup");
}
} finally {
ready.value = true;
}
});
</script>

<style>
Expand All @@ -21,4 +43,28 @@ body {
background-color: #f8fafc;
color: #1e293b;
}

.app-loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
}

.app-loading .logo-img {
animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.95);
}
}
</style>
19 changes: 19 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import type { Permission } from "@/types";
import { useAuthStore } from "@/stores/auth";
import { useSetupStore } from "@/stores/setup";
import DashboardLayout from "@/layouts/DashboardLayout.vue";

const routes: RouteRecordRaw[] = [
Expand All @@ -11,6 +12,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/LoginView.vue"),
meta: { requiresAuth: false },
},
{
path: "/setup",
name: "setup",
component: () => import("@/views/SetupView.vue"),
meta: { requiresAuth: false },
},
{
path: "/",
component: DashboardLayout,
Expand Down Expand Up @@ -181,6 +188,18 @@ const router = createRouter({
});

router.beforeEach((to, _from, next) => {
const setup = useSetupStore();

if (setup.initialized === false && to.path !== "/setup") {
next("/setup");
return;
}

if (setup.initialized === true && to.path === "/setup") {
next("/login");
return;
}

const token = localStorage.getItem("auth_token");
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth !== false);

Expand Down
4 changes: 2 additions & 2 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
DomainConfig,
} from "@/types";

const apiClient = axios.create({
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || "/api",
headers: {
"Content-Type": "application/json",
Expand All @@ -34,7 +34,7 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
if (error.response?.status === 401 && !window.location.pathname.includes("/setup")) {
localStorage.removeItem("auth_token");
window.location.href = "/login";
}
Expand Down
155 changes: 155 additions & 0 deletions src/stores/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { apiClient } from "@/services/api";

export interface SetupCheck {
name: string;
status: "pass" | "fail" | "warn";
message: string;
required: boolean;
}

export interface DNSResult {
valid: boolean;
domain: string;
expected: string;
actual: string[];
message?: string;
}

export interface AuthResult {
auth_method: string;
username?: string;
user_uid?: string;
api_key?: string;
api_key_id?: string;
}

export const useSetupStore = defineStore("setup", () => {
const initialized = ref<boolean | null>(null);
const instanceIp = ref("");
const agentVersion = ref("");
const loading = ref(false);
const error = ref("");

async function checkSetupStatus(force = false) {
if (!force && initialized.value !== null) return;
try {
const { data } = await apiClient.get("/setup/status");
initialized.value = data.initialized;
} catch (e: any) {
if (e.code === "ERR_NETWORK") {
error.value = "Unable to reach FlatRun Agent. Is the service running?";
} else {
error.value = e.response?.data?.error || "Failed to check setup status";
}
}
}

const infoLoaded = ref(false);

async function fetchSetupInfo() {
try {
const { data } = await apiClient.get("/setup/info");
instanceIp.value = data.instance_ip || "Unknown";
agentVersion.value =
typeof data.agent_version === "object" ? data.agent_version.version : data.agent_version || "Unknown";
} catch {
// non-critical, setup wizard still works without it
} finally {
infoLoaded.value = true;
}
}

async function runValidation(): Promise<SetupCheck[]> {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.post("/setup/validate");
return data.checks || [];
} catch (e: any) {
error.value = e.response?.data?.error || "Validation failed";
return [];
} finally {
loading.value = false;
}
}

async function verifyDNS(domain: string): Promise<DNSResult | null> {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.get("/setup/verify-dns", { params: { domain } });
return data;
} catch (e: any) {
error.value = e.response?.data?.error || "DNS verification failed";
return null;
} finally {
loading.value = false;
}
}

async function saveSettings(payload: { domain?: string; auto_ssl?: boolean; cors_origins?: string[] }) {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.post("/setup/settings", payload);
return data;
} catch (e: any) {
error.value = e.response?.data?.error || "Failed to save settings";
return null;
} finally {
loading.value = false;
}
}

async function configureAuth(payload: {
auth_method: string;
username?: string;
password?: string;
email?: string;
}): Promise<AuthResult | null> {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.post("/setup/authentication", payload);
return data;
} catch (e: any) {
error.value = e.response?.data?.error || "Failed to configure authentication";
return null;
} finally {
loading.value = false;
}
}

async function completeSetup() {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.post("/setup/complete");
initialized.value = true;
return data;
} catch (e: any) {
error.value = e.response?.data?.error || "Failed to complete setup";
return null;
} finally {
loading.value = false;
}
}

return {
initialized,
instanceIp,
agentVersion,
infoLoaded,
loading,
error,
checkSetupStatus,
fetchSetupInfo,
runValidation,
verifyDNS,
saveSettings,
configureAuth,
completeSetup,
};
});
6 changes: 6 additions & 0 deletions src/stores/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export const useStatsStore = defineStore("stats", () => {
docker.volumes = data.volumes?.total || 0;
docker.networks = data.networks?.total || 0;
docker.ports = data.ports?.total || 0;

system.ports = data.system_ports?.total || 0;
system.services = data.services?.total || 0;
system.infrastructure = data.infrastructure?.total || 0;
system.certificates = data.certificates?.total || 0;
system.apps = data.apps?.total || 0;
}

if (statsRes.data?.system) {
Expand Down
2 changes: 1 addition & 1 deletion src/views/LoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="login-left">
<div class="brand">
<Logo variant="full" size="lg" />
<p>Docker Deployment Manager</p>
<p>Containerized apps and server management</p>
</div>

<div class="features">
Expand Down
Loading
Loading