Skip to content

Commit e04a745

Browse files
committed
feat(ui): Add setup wizard and improve initial load experience
Add setup wizard view with multi-step flow for first-time server configuration. Show animated logo during initial load instead of blank page. Populate sidebar nav counts from /stats API. Signed-off-by: nfebe <fenn25.fn@gmail.com>
1 parent 00926f7 commit e04a745

11 files changed

Lines changed: 2546 additions & 1187 deletions

File tree

.env.production

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_API_URL=/api

.node-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
20

package-lock.json

Lines changed: 1414 additions & 1184 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@xterm/addon-web-links": "^0.11.0",
3232
"@xterm/xterm": "^5.5.0",
3333
"axios": "^1.6.7",
34+
"codemirror": "^6.0.2",
3435
"lucide-vue-next": "^0.554.0",
3536
"pinia": "^2.1.7",
3637
"primeicons": "^6.0.1",

src/App.vue

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
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+
await setup.checkSetupStatus();
24+
25+
if (setup.initialized === false) {
26+
router.replace("/setup");
27+
}
28+
29+
ready.value = true;
30+
});
1031
</script>
1132

1233
<style>
@@ -21,4 +42,21 @@ body {
2142
background-color: #f8fafc;
2243
color: #1e293b;
2344
}
45+
46+
.app-loading {
47+
min-height: 100vh;
48+
display: flex;
49+
align-items: center;
50+
justify-content: center;
51+
background: #f8fafc;
52+
}
53+
54+
.app-loading .logo-img {
55+
animation: pulse 1.5s ease-in-out infinite;
56+
}
57+
58+
@keyframes pulse {
59+
0%, 100% { opacity: 1; transform: scale(1); }
60+
50% { opacity: 0.5; transform: scale(0.95); }
61+
}
2462
</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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.startsWith("/setup")) {
3838
localStorage.removeItem("auth_token");
3939
window.location.href = "/login";
4040
}

src/stores/setup.ts

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

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)