Skip to content

Commit ef57429

Browse files
authored
Merge pull request #50 from flatrun/feat/setup-architecture-redesign
feat(ui): Add setup wizard and improve initial load experience
2 parents 00926f7 + 4b6a8b4 commit ef57429

9 files changed

Lines changed: 1192 additions & 13 deletions

File tree

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
VITE_API_URL=http://localhost:8080
1+
# FlatRun UI Environment Variables
2+
# Copy this file to .env.local and adjust values for your environment.
3+
# .env.local is gitignored and will not be committed.
4+
5+
# API base URL — defaults to "/api" (works with nginx proxy in production)
6+
# For local development, point to the agent directly:
7+
VITE_API_URL=http://localhost:8090/api

.github/workflows/ci.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ jobs:
1616
- name: Setup Node.js
1717
uses: actions/setup-node@v4
1818
with:
19-
node-version: '20'
19+
node-version: '22'
2020
cache: 'npm'
2121

2222
- name: Install dependencies
23-
run: npm ci
23+
run: npm install
2424

2525
- name: Run ESLint
2626
run: npm run lint
@@ -37,11 +37,11 @@ jobs:
3737
- name: Setup Node.js
3838
uses: actions/setup-node@v4
3939
with:
40-
node-version: '20'
40+
node-version: '22'
4141
cache: 'npm'
4242

4343
- name: Install dependencies
44-
run: npm ci
44+
run: npm install
4545

4646
- name: Run type check
4747
run: npm run type-check
@@ -55,11 +55,11 @@ jobs:
5555
- name: Setup Node.js
5656
uses: actions/setup-node@v4
5757
with:
58-
node-version: '20'
58+
node-version: '22'
5959
cache: 'npm'
6060

6161
- name: Install dependencies
62-
run: npm ci
62+
run: npm install
6363

6464
- name: Run tests
6565
run: npm run test:run
@@ -74,11 +74,11 @@ jobs:
7474
- name: Setup Node.js
7575
uses: actions/setup-node@v4
7676
with:
77-
node-version: '20'
77+
node-version: '22'
7878
cache: 'npm'
7979

8080
- name: Install dependencies
81-
run: npm ci
81+
run: npm install
8282

8383
- name: Build
8484
run: npm run build

src/App.vue

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
<template>
22
<div id="app">
33
<ToastNotifications />
4-
<router-view />
4+
<div v-if="!ready" class="app-loading">
5+
<Logo variant="icon" size="lg" />
6+
</div>
7+
<router-view v-else />
58
</div>
69
</template>
710

811
<script setup lang="ts">
12+
import { ref, onMounted } from "vue";
913
import ToastNotifications from "@/components/ToastNotifications.vue";
14+
import Logo from "@/components/base/Logo.vue";
15+
import { useSetupStore } from "@/stores/setup";
16+
import { useRouter } from "vue-router";
17+
18+
const ready = ref(false);
19+
const router = useRouter();
20+
const setup = useSetupStore();
21+
22+
onMounted(async () => {
23+
try {
24+
await setup.checkSetupStatus();
25+
if (setup.initialized === false) {
26+
router.replace("/setup");
27+
}
28+
} finally {
29+
ready.value = true;
30+
}
31+
});
1032
</script>
1133

1234
<style>
@@ -21,4 +43,28 @@ body {
2143
background-color: #f8fafc;
2244
color: #1e293b;
2345
}
46+
47+
.app-loading {
48+
min-height: 100vh;
49+
display: flex;
50+
align-items: center;
51+
justify-content: center;
52+
background: #f8fafc;
53+
}
54+
55+
.app-loading .logo-img {
56+
animation: pulse 1.5s ease-in-out infinite;
57+
}
58+
59+
@keyframes pulse {
60+
0%,
61+
100% {
62+
opacity: 1;
63+
transform: scale(1);
64+
}
65+
50% {
66+
opacity: 0.5;
67+
transform: scale(0.95);
68+
}
69+
}
2470
</style>

src/router/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
22
import type { RouteRecordRaw } from "vue-router";
33
import type { Permission } from "@/types";
44
import { useAuthStore } from "@/stores/auth";
5+
import { useSetupStore } from "@/stores/setup";
56
import DashboardLayout from "@/layouts/DashboardLayout.vue";
67

78
const routes: RouteRecordRaw[] = [
@@ -11,6 +12,12 @@ const routes: RouteRecordRaw[] = [
1112
component: () => import("@/views/LoginView.vue"),
1213
meta: { requiresAuth: false },
1314
},
15+
{
16+
path: "/setup",
17+
name: "setup",
18+
component: () => import("@/views/SetupView.vue"),
19+
meta: { requiresAuth: false },
20+
},
1421
{
1522
path: "/",
1623
component: DashboardLayout,
@@ -181,6 +188,18 @@ const router = createRouter({
181188
});
182189

183190
router.beforeEach((to, _from, next) => {
191+
const setup = useSetupStore();
192+
193+
if (setup.initialized === false && to.path !== "/setup") {
194+
next("/setup");
195+
return;
196+
}
197+
198+
if (setup.initialized === true && to.path === "/setup") {
199+
next("/login");
200+
return;
201+
}
202+
184203
const token = localStorage.getItem("auth_token");
185204
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth !== false);
186205

src/services/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
DomainConfig,
1717
} from "@/types";
1818

19-
const apiClient = axios.create({
19+
export const apiClient = axios.create({
2020
baseURL: import.meta.env.VITE_API_URL || "/api",
2121
headers: {
2222
"Content-Type": "application/json",
@@ -34,7 +34,7 @@ apiClient.interceptors.request.use((config) => {
3434
apiClient.interceptors.response.use(
3535
(response) => response,
3636
(error) => {
37-
if (error.response?.status === 401) {
37+
if (error.response?.status === 401 && !window.location.pathname.includes("/setup")) {
3838
localStorage.removeItem("auth_token");
3939
window.location.href = "/login";
4040
}

src/stores/setup.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { defineStore } from "pinia";
2+
import { ref } from "vue";
3+
import { apiClient } from "@/services/api";
4+
5+
export interface SetupCheck {
6+
name: string;
7+
status: "pass" | "fail" | "warn";
8+
message: string;
9+
required: boolean;
10+
}
11+
12+
export interface DNSResult {
13+
valid: boolean;
14+
domain: string;
15+
expected: string;
16+
actual: string[];
17+
message?: string;
18+
}
19+
20+
export interface AuthResult {
21+
auth_method: string;
22+
username?: string;
23+
user_uid?: string;
24+
api_key?: string;
25+
api_key_id?: string;
26+
}
27+
28+
export const useSetupStore = defineStore("setup", () => {
29+
const initialized = ref<boolean | null>(null);
30+
const instanceIp = ref("");
31+
const agentVersion = ref("");
32+
const loading = ref(false);
33+
const error = ref("");
34+
35+
async function checkSetupStatus(force = false) {
36+
if (!force && initialized.value !== null) return;
37+
try {
38+
const { data } = await apiClient.get("/setup/status");
39+
initialized.value = data.initialized;
40+
} catch (e: any) {
41+
if (e.code === "ERR_NETWORK") {
42+
error.value = "Unable to reach FlatRun Agent. Is the service running?";
43+
} else {
44+
error.value = e.response?.data?.error || "Failed to check setup status";
45+
}
46+
}
47+
}
48+
49+
const infoLoaded = ref(false);
50+
51+
async function fetchSetupInfo() {
52+
try {
53+
const { data } = await apiClient.get("/setup/info");
54+
instanceIp.value = data.instance_ip || "Unknown";
55+
agentVersion.value =
56+
typeof data.agent_version === "object" ? data.agent_version.version : data.agent_version || "Unknown";
57+
} catch {
58+
// non-critical, setup wizard still works without it
59+
} finally {
60+
infoLoaded.value = true;
61+
}
62+
}
63+
64+
async function runValidation(): Promise<SetupCheck[]> {
65+
loading.value = true;
66+
error.value = "";
67+
try {
68+
const { data } = await apiClient.post("/setup/validate");
69+
return data.checks || [];
70+
} catch (e: any) {
71+
error.value = e.response?.data?.error || "Validation failed";
72+
return [];
73+
} finally {
74+
loading.value = false;
75+
}
76+
}
77+
78+
async function verifyDNS(domain: string): Promise<DNSResult | null> {
79+
loading.value = true;
80+
error.value = "";
81+
try {
82+
const { data } = await apiClient.get("/setup/verify-dns", { params: { domain } });
83+
return data;
84+
} catch (e: any) {
85+
error.value = e.response?.data?.error || "DNS verification failed";
86+
return null;
87+
} finally {
88+
loading.value = false;
89+
}
90+
}
91+
92+
async function saveSettings(payload: { domain?: string; auto_ssl?: boolean; cors_origins?: string[] }) {
93+
loading.value = true;
94+
error.value = "";
95+
try {
96+
const { data } = await apiClient.post("/setup/settings", payload);
97+
return data;
98+
} catch (e: any) {
99+
error.value = e.response?.data?.error || "Failed to save settings";
100+
return null;
101+
} finally {
102+
loading.value = false;
103+
}
104+
}
105+
106+
async function configureAuth(payload: {
107+
auth_method: string;
108+
username?: string;
109+
password?: string;
110+
email?: string;
111+
}): Promise<AuthResult | null> {
112+
loading.value = true;
113+
error.value = "";
114+
try {
115+
const { data } = await apiClient.post("/setup/authentication", payload);
116+
return data;
117+
} catch (e: any) {
118+
error.value = e.response?.data?.error || "Failed to configure authentication";
119+
return null;
120+
} finally {
121+
loading.value = false;
122+
}
123+
}
124+
125+
async function completeSetup() {
126+
loading.value = true;
127+
error.value = "";
128+
try {
129+
const { data } = await apiClient.post("/setup/complete");
130+
initialized.value = true;
131+
return data;
132+
} catch (e: any) {
133+
error.value = e.response?.data?.error || "Failed to complete setup";
134+
return null;
135+
} finally {
136+
loading.value = false;
137+
}
138+
}
139+
140+
return {
141+
initialized,
142+
instanceIp,
143+
agentVersion,
144+
infoLoaded,
145+
loading,
146+
error,
147+
checkSetupStatus,
148+
fetchSetupInfo,
149+
runValidation,
150+
verifyDNS,
151+
saveSettings,
152+
configureAuth,
153+
completeSetup,
154+
};
155+
});

src/stores/stats.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export const useStatsStore = defineStore("stats", () => {
7070
docker.volumes = data.volumes?.total || 0;
7171
docker.networks = data.networks?.total || 0;
7272
docker.ports = data.ports?.total || 0;
73+
74+
system.ports = data.system_ports?.total || 0;
75+
system.services = data.services?.total || 0;
76+
system.infrastructure = data.infrastructure?.total || 0;
77+
system.certificates = data.certificates?.total || 0;
78+
system.apps = data.apps?.total || 0;
7379
}
7480

7581
if (statsRes.data?.system) {

src/views/LoginView.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<div class="login-left">
44
<div class="brand">
55
<Logo variant="full" size="lg" />
6-
<p>Docker Deployment Manager</p>
6+
<p>Containerized apps and server management</p>
77
</div>
88

99
<div class="features">

0 commit comments

Comments
 (0)