4b60ee0e-4981-4301-aad2-220e7e9c760a — Commit e55aedb6

AuthorMikkel Thygesen<Mikkelet@gmail.com>
Date2026-04-25 16:05:16 +0200
latest changes

Changed files

.env.development                |   2 +-
 .env.staging                    |   2 +-
 Dockerfile                      |   5 +
 package.json                    |   3 +
 src/api/client.ts               | 117 +++++++++++-------
 src/components/AppLayout.vue    |   6 +
 src/components/DeeplinkCard.vue |  92 ++-------------
 src/components/LaunchModal.vue  | 104 +++++++++++-----
 src/components/OrgSidebar.vue   |  42 ++++++-
 src/main.ts                     |   8 +-
 src/router/index.ts             |   6 +
 src/stores/history.ts           |   2 +-
 src/stores/logs.ts              |  76 ++++++++++++
 src/stores/organizations.ts     |   8 ++
 src/views/AddDeeplinkView.vue   |  13 +-
 src/views/AppDetailView.vue     |  39 ++++--
 src/views/DebugView.vue         | 254 ++++++++++++++++++++++++++++++++++++++++
 vite.config.ts                  |   1 +
 18 files changed, 596 insertions(+), 184 deletions(-)

Diff

diff --git a/.env.development b/.env.development
index 3d862c1..14ea4ad 100644
--- a/.env.development
+++ b/.env.development
@@ -1 +1 @@
-VITE_API_BASE_URL=http://localhost:3200
+VITE_API_BASE_URL=/api
diff --git a/.env.staging b/.env.staging
index 3d862c1..14ea4ad 100644
--- a/.env.staging
+++ b/.env.staging
@@ -1 +1 @@
-VITE_API_BASE_URL=http://localhost:3200
+VITE_API_BASE_URL=/api
diff --git a/Dockerfile b/Dockerfile
index 4ff441e..e8a44cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,11 @@ RUN printf 'server {\n\
listen 80;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
+ location /api/ {\n\
+ proxy_pass http://diver-api:3200/;\n\
+ proxy_set_header Host $host;\n\
+ proxy_set_header X-Real-IP $remote_addr;\n\
+ }\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
diff --git a/package.json b/package.json
index 4850c18..0f41b44 100644
--- a/package.json
+++ b/package.json
@@ -22,5 +22,8 @@
"typescript": "^5.9.3",
"vite": "^5.0.12",
"vue-tsc": "^2.2.0"
+ },
+ "rules": {
+ "no-console": false
}
}
diff --git a/src/api/client.ts b/src/api/client.ts
index e0c9346..fa4fbb8 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -1,83 +1,110 @@
import axios from 'axios'
-import type { Organization, App, DeeplinkTemplate } from '@/types'
+import type {Organization, App, DeeplinkTemplate, Environment} from '@/types'
+
+// API returns environments as { name: scheme } map; client expects Environment[].
+type ApiApp = Omit<App, 'environments'> & { environments: Record<string, string> }
+
+function normalizeApp(api: ApiApp): App {
+ const envs: Environment[] = api.environments
+ ? Object.entries(api.environments).map(([name, scheme]) => ({name, scheme}))
+ : []
+ return {...api, environments: envs}
+}
const api = axios.create({
- baseURL: import.meta.env.VITE_API_BASE_URL,
- headers: {
- 'Content-Type': 'application/json',
- },
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
})
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ const {config, response} = error
+ const method = config?.method?.toUpperCase() ?? 'REQUEST'
+ const url = config?.url ?? ''
+ if (response) {
+ console.error(`[API] ${method} ${url} → ${response.status} ${response.statusText}`, response.data)
+ } else {
+ console.error(`[API] ${method} ${url} → network error`, error.message)
+ }
+ return Promise.reject(error)
+ },
+)
+
// Organizations
-export const getOrganizations = () =>
- api.get<Organization[]>('/organizations').then(r => r.data)
+export async function getOrganizations() {
+ const response = await api.get<Organization[]>('/organizations')
+ return response.data
+}
export const createOrganization = (name: string) =>
- api.post<Organization>('/organizations', { name }).then(r => r.data)
+ api.post<Organization>('/organizations', {name}).then(r => r.data)
export const getOrganization = (orgId: string) =>
- api.get<Organization>(`/organizations/${orgId}`).then(r => r.data)
+ api.get<Organization>(`/organizations/${orgId}`).then(r => r.data)
export const deleteOrganization = (orgId: string) =>
- api.delete(`/organizations/${orgId}`)
+ api.delete(`/organizations/${orgId}`)
// Apps
export const getApps = (orgId: string) =>
- api.get<App[]>(`/organizations/${orgId}/apps`).then(r => r.data)
+ api.get<ApiApp[]>(`/organizations/${orgId}/apps`).then(r => r.data.map(normalizeApp))
export const createApp = (
- orgId: string,
- data: { name: string; environments: Record<string, string> }
-) => api.post<App>(`/organizations/${orgId}/apps`, data).then(r => r.data)
+ orgId: string,
+ data: { name: string; environments: Record<string, string> }
+) => api.post<ApiApp>(`/organizations/${orgId}/apps`, data).then(r => normalizeApp(r.data))
export const getApp = (orgId: string, appId: string) =>
- api.get<App>(`/organizations/${orgId}/apps/${appId}`).then(r => r.data)
+ api.get<ApiApp>(`/organizations/${orgId}/apps/${appId}`).then(r => normalizeApp(r.data))
export const updateApp = (
- orgId: string,
- appId: string,
- data: { name: string; environments: Record<string, string> }
-) => api.put<App>(`/organizations/${orgId}/apps/${appId}`, data).then(r => r.data)
+ orgId: string,
+ appId: string,
+ data: { name: string; environments: Record<string, string> }
+) => api.put<ApiApp>(`/organizations/${orgId}/apps/${appId}`, data).then(r => normalizeApp(r.data))
export const deleteApp = (orgId: string, appId: string) =>
- api.delete(`/organizations/${orgId}/apps/${appId}`)
+ api.delete(`/organizations/${orgId}/apps/${appId}`)
// Deeplinks
export const getDeeplinks = (orgId: string, appId: string) =>
- api
- .get<DeeplinkTemplate[]>(`/organizations/${orgId}/apps/${appId}/deeplinks`)
- .then(r => r.data)
+ api
+ .get<DeeplinkTemplate[]>(`/organizations/${orgId}/apps/${appId}/deeplinks`)
+ .then(r => r.data)
export const createDeeplink = (
- orgId: string,
- appId: string,
- data: Omit<DeeplinkTemplate, 'id' | 'appId'>
+ orgId: string,
+ appId: string,
+ data: Omit<DeeplinkTemplate, 'id' | 'appId'>
) =>
- api
- .post<DeeplinkTemplate>(`/organizations/${orgId}/apps/${appId}/deeplinks`, data)
- .then(r => r.data)
+ api
+ .post<DeeplinkTemplate>(`/organizations/${orgId}/apps/${appId}/deeplinks`, data)
+ .then(r => r.data)
export const getDeeplink = (orgId: string, appId: string, deeplinkId: string) =>
- api
- .get<DeeplinkTemplate>(
- `/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`
- )
- .then(r => r.data)
+ api
+ .get<DeeplinkTemplate>(
+ `/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`
+ )
+ .then(r => r.data)
export const updateDeeplink = (
- orgId: string,
- appId: string,
- deeplinkId: string,
- data: Omit<DeeplinkTemplate, 'id' | 'appId'>
+ orgId: string,
+ appId: string,
+ deeplinkId: string,
+ data: Omit<DeeplinkTemplate, 'id' | 'appId'>
) =>
- api
- .put<DeeplinkTemplate>(
- `/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`,
- data
- )
- .then(r => r.data)
+ api
+ .put<DeeplinkTemplate>(
+ `/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`,
+ data
+ )
+ .then(r => r.data)
export const deleteDeeplink = (orgId: string, appId: string, deeplinkId: string) =>
- api.delete(`/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`)
+ api.delete(`/organizations/${orgId}/apps/${appId}/deeplinks/${deeplinkId}`)
export default api
diff --git a/src/components/AppLayout.vue b/src/components/AppLayout.vue
index 4f8dfd6..cdeed67 100644
--- a/src/components/AppLayout.vue
+++ b/src/components/AppLayout.vue
@@ -43,6 +43,12 @@ function goHome() {
<span v-if="envBadge" :class="['env-badge', envBadge.class]">{{ envBadge.label }}</span>
</div>
<div class="header-right">
+ <RouterLink v-if="envBadge" to="/debug" class="btn btn-ghost btn-sm history-link">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
+ </svg>
+ Debug
+ </RouterLink>
<RouterLink to="/launch-history" class="btn btn-ghost btn-sm history-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
diff --git a/src/components/DeeplinkCard.vue b/src/components/DeeplinkCard.vue
index dbc5525..d279fab 100644
--- a/src/components/DeeplinkCard.vue
+++ b/src/components/DeeplinkCard.vue
@@ -1,38 +1,22 @@
<script setup lang="ts">
-import type {DeeplinkTemplate} from '@/types'
+import type {DeeplinkTemplate, Environment} from '@/types'
const props = defineProps<{
deeplink: DeeplinkTemplate
orgId: string
appId: string
+ environment: Environment | null
}>()
const emit = defineEmits<{
launch: [deeplink: DeeplinkTemplate]
- edit: [deeplink: DeeplinkTemplate]
- delete: [deeplinkId: string]
}>()
-function launch(e: Event) {
- e.stopPropagation()
- emit('launch', props.deeplink)
-}
-
-function editDeeplink(e: Event) {
- e.stopPropagation()
- emit('edit', props.deeplink)
-}
-
-function deleteDeeplink(e: Event) {
- e.stopPropagation()
- emit('delete', props.deeplink.id)
-}
-
const paramCount = Object.keys(props.deeplink.queryParams).length
</script>
<template>
- <div class="deeplink-card" @click="launch">
+ <div class="deeplink-card" @click="emit('launch', deeplink)">
<div class="deeplink-main">
<div class="deeplink-header">
<span class="deeplink-name">{{ deeplink.name }}</span>
@@ -41,34 +25,14 @@ const paramCount = Object.keys(props.deeplink.queryParams).length
</div>
</div>
<div class="deeplink-path">
+ <span v-if="environment" class="path-scheme">{{ environment.scheme.replace(/:\/?\/?$/, '') }}://</span>
<span class="path-host">{{ deeplink.host }}</span>
- <span class="path-segment">{{ deeplink.path || '/' }}</span>
+ <span class="path-segment">{{ deeplink.path?.startsWith('/') ? deeplink.path : `/${deeplink.path || ''}` }}</span>
</div>
<div v-if="deeplink.description" class="deeplink-desc">
{{ deeplink.description }}
</div>
</div>
- <div class="deeplink-actions">
- <button class="action-btn launch-btn" title="Launch deeplink" @click="launch">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
- <path d="M5 12h14M12 5l7 7-7 7"/>
- </svg>
- </button>
- <button class="action-btn" title="Edit deeplink" @click="editDeeplink">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
- <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
- </svg>
- </button>
- <button class="action-btn action-btn-danger" title="Delete deeplink" @click="deleteDeeplink">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <polyline points="3 6 5 6 21 6"/>
- <path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>
- <path d="M10 11v6M14 11v6"/>
- <path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/>
- </svg>
- </button>
- </div>
</div>
</template>
@@ -123,6 +87,11 @@ const paramCount = Object.keys(props.deeplink.queryParams).length
margin-bottom: 2px;
}
+.path-scheme {
+ color: var(--color-text-muted);
+ opacity: 0.6;
+}
+
.path-host {
color: var(--color-primary);
opacity: 0.8;
@@ -140,45 +109,4 @@ const paramCount = Object.keys(props.deeplink.queryParams).length
overflow: hidden;
text-overflow: ellipsis;
}
-
-.deeplink-actions {
- display: flex;
- gap: 4px;
- opacity: 0;
- transition: opacity 0.15s;
- flex-shrink: 0;
-}
-
-.deeplink-card:hover .deeplink-actions {
- opacity: 1;
-}
-
-.action-btn {
- width: 30px;
- height: 30px;
- border-radius: 6px;
- border: none;
- background: var(--color-surface-raised);
- color: var(--color-text-muted);
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: background 0.12s, color 0.12s;
-}
-
-.action-btn:hover {
- background: var(--color-border);
- color: var(--color-text);
-}
-
-.action-btn-danger:hover {
- background: rgba(239, 68, 68, 0.1);
- color: var(--color-error);
-}
-
-.launch-btn:hover {
- background: var(--color-primary);
- color: #fff;
-}
</style>
diff --git a/src/components/LaunchModal.vue b/src/components/LaunchModal.vue
index 3e2e2fd..c82e703 100644
--- a/src/components/LaunchModal.vue
+++ b/src/components/LaunchModal.vue
@@ -6,21 +6,22 @@ import { useHistoryStore } from '@/stores/history'
const props = defineProps<{
deeplink: DeeplinkTemplate
app: App
+ environment: Environment
}>()
-const emit = defineEmits<{ close: [] }>()
+const emit = defineEmits<{
+ close: []
+ edit: [deeplink: DeeplinkTemplate]
+ delete: [deeplinkId: string]
+}>()
const historyStore = useHistoryStore()
-// Selected environment
-const selectedEnvIndex = ref(0)
-const selectedEnv = computed<Environment | null>(
- () => props.app.environments[selectedEnvIndex.value] ?? null
-)
+const selectedEnv = computed(() => props.environment)
// Extract path params from `:paramName` tokens
const pathParams = computed<string[]>(() => {
- const matches = props.deeplink.path.match(/:[a-zA-Z]+/g) ?? []
+ const matches = (props.deeplink.path ?? '').match(/:[a-zA-Z]+/g) ?? []
return matches.map(m => m.slice(1))
})
@@ -68,16 +69,25 @@ function updateListItem(key: string, index: number, val: string) {
queryValues.value[key] = arr
}
+const unfilledParams = computed(() =>
+ pathParams.value.filter(p => !pathValues.value[p]?.trim())
+)
+
// Build URI
const builtUri = computed<string>(() => {
const env = selectedEnv.value
if (!env) return ''
+ // Normalize scheme: strip trailing :// or : if the user included it
+ const scheme = env.scheme.replace(/:\/?\/?$/, '')
+
// Fill path params
- let filledPath = props.deeplink.path
+ let filledPath = props.deeplink.path ?? ''
for (const [key, val] of Object.entries(pathValues.value)) {
- filledPath = filledPath.replace(`:${key}`, encodeURIComponent(val || `:${key}`))
+ filledPath = filledPath.replace(`:${key}`, val.trim() || `:${key}`)
}
+ // Ensure path starts with / when non-empty
+ if (filledPath && !filledPath.startsWith('/')) filledPath = `/${filledPath}`
// Build query string
const queryParts: string[] = []
@@ -99,9 +109,9 @@ const builtUri = computed<string>(() => {
}
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
- const fragment = props.deeplink.fragment ? `#${props.deeplink.fragment}` : ''
+ const fragment = props.deeplink.fragment ? `#${encodeURIComponent(props.deeplink.fragment)}` : ''
- return `${env.scheme}://${props.deeplink.host}${filledPath}${queryString}${fragment}`
+ return `${scheme}://${props.deeplink.host}${filledPath}${queryString}${fragment}`
})
function launch() {
@@ -134,29 +144,26 @@ function onOverlayClick(e: MouseEvent) {
<div class="modal-title">{{ deeplink.name }}</div>
<div class="modal-subtitle" v-if="deeplink.description">{{ deeplink.description }}</div>
</div>
- <button class="modal-close" @click="emit('close')">✕</button>
+ <div class="modal-header-actions">
+ <button class="action-btn" title="Edit deeplink" @click="emit('edit', deeplink)">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
+ </svg>
+ </button>
+ <button class="action-btn action-btn-danger" title="Delete deeplink" @click="emit('delete', deeplink.id)">
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <polyline points="3 6 5 6 21 6"/>
+ <path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/>
+ <path d="M10 11v6M14 11v6"/>
+ <path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/>
+ </svg>
+ </button>
+ <button class="modal-close" @click="emit('close')">✕</button>
+ </div>
</div>
<div class="modal-body">
- <!-- Environment selector -->
- <div v-if="app.environments.length > 0" class="section">
- <div class="section-label">Environment</div>
- <div class="env-tabs">
- <button
- v-for="(env, idx) in app.environments"
- :key="idx"
- :class="['env-tab', { active: selectedEnvIndex === idx }]"
- @click="selectedEnvIndex = idx"
- >
- {{ env.name }}
- </button>
- </div>
- </div>
-
- <div v-else class="error-message">
- This app has no environments configured.
- </div>
-
<!-- Path params -->
<div v-if="pathParams.length > 0" class="section">
<div class="section-label">Path Parameters</div>
@@ -272,11 +279,14 @@ function onOverlayClick(e: MouseEvent) {
</div>
<!-- Actions -->
+ <div v-if="unfilledParams.length > 0" class="error-message" style="margin-bottom: 12px">
+ Fill required path params: {{ unfilledParams.join(', ') }}
+ </div>
<div class="launch-actions">
<button class="btn btn-secondary" @click="emit('close')">Cancel</button>
<button
class="btn btn-primary launch-btn"
- :disabled="!selectedEnv"
+ :disabled="unfilledParams.length > 0"
@click="launch"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@@ -301,6 +311,36 @@ function onOverlayClick(e: MouseEvent) {
margin-top: 2px;
}
+.modal-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.action-btn {
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ border: none;
+ background: var(--color-surface-raised);
+ color: var(--color-text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+
+.action-btn:hover {
+ background: var(--color-border);
+ color: var(--color-text);
+}
+
+.action-btn-danger:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: var(--color-error);
+}
+
.section {
margin-bottom: 20px;
}
diff --git a/src/components/OrgSidebar.vue b/src/components/OrgSidebar.vue
index a90882f..4873a2c 100644
--- a/src/components/OrgSidebar.vue
+++ b/src/components/OrgSidebar.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
-import { computed, onMounted } from 'vue'
+import {computed, onMounted, ref} from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useOrganizationsStore } from '@/stores/organizations'
@@ -8,6 +8,8 @@ const emit = defineEmits<{ navigate: [] }>()
const router = useRouter()
const route = useRoute()
const orgStore = useOrganizationsStore()
+const orgsError = ref<string | null>(null)
+
const currentOrgId = computed(() => route.params.orgId as string | undefined)
@@ -21,6 +23,16 @@ function addOrg() {
emit('navigate')
}
+async function fetchOrgs() {
+ if (import.meta.env.MODE === 'staging') {
+ try {
+ await orgStore.fetchAllOrganizations()
+ } catch (e: any) {
+ orgsError.value = e.toString()
+ }
+ }
+}
+
function removeOrg(e: Event, orgId: string) {
e.stopPropagation()
if (confirm('Remove this organization from your list?')) {
@@ -31,10 +43,8 @@ function removeOrg(e: Event, orgId: string) {
}
}
-onMounted(() => {
- if (!import.meta.env.PROD) {
- orgStore.fetchAllOrganizations()
- }
+onMounted(async () => {
+ fetchOrgs()
})
async function copyOrgId(e: Event, orgId: string) {
@@ -84,7 +94,12 @@ async function copyOrgId(e: Event, orgId: string) {
</div>
</div>
- <div v-if="orgStore.organizations.length === 0" class="no-orgs">
+ <div v-if="orgsError" class="fetch-error">
+ <p>{{ orgsError }}</p>
+ <button class="btn btn-sm btn-secondary" @click="fetchOrgs()">Retry</button>
+ </div>
+
+ <div v-else-if="orgStore.organizations.length === 0" class="no-orgs">
<p>No organizations yet</p>
</div>
</div>
@@ -220,6 +235,21 @@ async function copyOrgId(e: Event, orgId: string) {
color: var(--color-error);
}
+.fetch-error {
+ padding: 12px;
+ margin: 8px;
+ border-radius: 8px;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ text-align: center;
+ font-size: 12px;
+ color: var(--color-error);
+}
+
+.fetch-error p {
+ margin: 0 0 8px;
+}
+
.no-orgs {
padding: 24px 12px;
text-align: center;
diff --git a/src/main.ts b/src/main.ts
index 77b04e9..99620db 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,12 @@ import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
-app.use(createPinia())
+const pinia = createPinia()
+app.use(pinia)
app.use(router)
app.mount('#app')
+
+console.log("hello world")
+
+import { useLogsStore } from './stores/logs'
+useLogsStore().install()
diff --git a/src/router/index.ts b/src/router/index.ts
index 7a1c190..b1428ae 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -5,6 +5,7 @@ import AppDetailView from '@/views/AppDetailView.vue'
import AddAppView from '@/views/AddAppView.vue'
import AddDeeplinkView from '@/views/AddDeeplinkView.vue'
import LaunchHistoryView from '@/views/LaunchHistoryView.vue'
+import DebugView from '@/views/DebugView.vue'
const router = createRouter({
history: createWebHistory(),
@@ -54,6 +55,11 @@ const router = createRouter({
name: 'launch-history',
component: LaunchHistoryView,
},
+ {
+ path: '/debug',
+ name: 'debug',
+ component: DebugView,
+ },
],
})
diff --git a/src/stores/history.ts b/src/stores/history.ts
index 175b9de..9b585ce 100644
--- a/src/stores/history.ts
+++ b/src/stores/history.ts
@@ -22,7 +22,7 @@ export const useHistoryStore = defineStore('history', () => {
function addEntry(entry: Omit<LaunchHistoryEntry, 'id' | 'timestamp'>) {
const newEntry: LaunchHistoryEntry = {
- id: crypto.randomUUID(),
+ id: self.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36),
timestamp: new Date().toISOString(),
...entry,
}
diff --git a/src/stores/logs.ts b/src/stores/logs.ts
new file mode 100644
index 0000000..d8235b7
--- /dev/null
+++ b/src/stores/logs.ts
@@ -0,0 +1,76 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export type LogLevel = 'log' | 'info' | 'warn' | 'error'
+
+export interface LogEntry {
+ id: string
+ timestamp: string
+ level: LogLevel
+ message: string
+}
+
+const MAX_ENTRIES = 500
+
+function serialize(args: unknown[]): string {
+ return args
+ .map((a) => {
+ if (typeof a === 'string') return a
+ try {
+ return JSON.stringify(a, null, 2)
+ } catch {
+ return String(a)
+ }
+ })
+ .join(' ')
+}
+
+export const useLogsStore = defineStore('logs', () => {
+ const entries = ref<LogEntry[]>([])
+ let installed = false
+
+ function addEntry(level: LogLevel, message: string) {
+ entries.value.unshift({
+ id: self.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36),
+ timestamp: new Date().toISOString(),
+ level,
+ message,
+ })
+ if (entries.value.length > MAX_ENTRIES) {
+ entries.value = entries.value.slice(0, MAX_ENTRIES)
+ }
+ }
+
+ function install() {
+ if (installed) return
+ installed = true
+
+ const origLog = console.log.bind(console)
+ const origInfo = console.info.bind(console)
+ const origWarn = console.warn.bind(console)
+ const origError = console.error.bind(console)
+
+ console.log = (...args: unknown[]) => {
+ origLog(...args)
+ addEntry('log', serialize(args))
+ }
+ console.info = (...args: unknown[]) => {
+ origInfo(...args)
+ addEntry('info', serialize(args))
+ }
+ console.warn = (...args: unknown[]) => {
+ origWarn(...args)
+ addEntry('warn', serialize(args))
+ }
+ console.error = (...args: unknown[]) => {
+ origError(...args)
+ addEntry('error', serialize(args))
+ }
+ }
+
+ function clear() {
+ entries.value = []
+ }
+
+ return { entries, install, clear }
+})
diff --git a/src/stores/organizations.ts b/src/stores/organizations.ts
index 694929a..accf31d 100644
--- a/src/stores/organizations.ts
+++ b/src/stores/organizations.ts
@@ -61,7 +61,12 @@ export const useOrganizationsStore = defineStore('organizations', () => {
apps.value = apps.value.filter(a => a.id !== appId)
}
+ const fetchOrgsLoading = ref(false)
+ const fetchOrgsError = ref<string | null>(null)
+
async function fetchAllOrganizations() {
+ fetchOrgsLoading.value = true
+ fetchOrgsError.value = null
const all = await getOrganizations()
for (const org of all) {
if (!organizations.value.find(o => o.id === org.id)) {
@@ -69,6 +74,7 @@ export const useOrganizationsStore = defineStore('organizations', () => {
}
}
saveToStorage(organizations.value)
+ fetchOrgsLoading.value = false
}
function addApp(app: App) {
@@ -95,5 +101,7 @@ export const useOrganizationsStore = defineStore('organizations', () => {
removeApp,
addApp,
fetchAllOrganizations,
+ fetchOrgsLoading,
+ fetchOrgsError,
}
})
diff --git a/src/views/AddDeeplinkView.vue b/src/views/AddDeeplinkView.vue
index 18fc664..f28fdf9 100644
--- a/src/views/AddDeeplinkView.vue
+++ b/src/views/AddDeeplinkView.vue
@@ -40,11 +40,11 @@ onMounted(async () => {
if (isEdit.value && deeplinkId) {
const dl = await getDeeplink(orgId, appId, deeplinkId)
- name.value = dl.name
- description.value = dl.description
- host.value = dl.host
- path.value = dl.path
- fragment.value = dl.fragment
+ name.value = dl.name ?? ''
+ description.value = dl.description ?? ''
+ host.value = dl.host ?? ''
+ path.value = dl.path ?? ''
+ fragment.value = dl.fragment ?? ''
queryParams.value = { ...dl.queryParams }
}
} catch {
@@ -100,7 +100,8 @@ const previewUri = computed(() => {
.join('&')
const qStr = q ? `?${q}` : ''
const frag = fragment.value ? `#${fragment.value}` : ''
- return `[scheme]://${host.value}${path.value || '/'}${qStr}${frag}`
+ const p = path.value ? (path.value.startsWith('/') ? path.value : `/${path.value}`) : '/'
+ return `[scheme]://${host.value}${p}${qStr}${frag}`
})
</script>
diff --git a/src/views/AppDetailView.vue b/src/views/AppDetailView.vue
index 64a04ff..e313062 100644
--- a/src/views/AppDetailView.vue
+++ b/src/views/AppDetailView.vue
@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrganizationsStore } from '@/stores/organizations'
import { getApp, getDeeplinks, deleteDeeplink } from '@/api/client'
-import type { DeeplinkTemplate, App } from '@/types'
+import type { DeeplinkTemplate, App, Environment } from '@/types'
import AppLayout from '@/components/AppLayout.vue'
import DeeplinkCard from '@/components/DeeplinkCard.vue'
import LaunchModal from '@/components/LaunchModal.vue'
@@ -21,6 +21,10 @@ const loading = ref(true)
const error = ref<string | null>(null)
const launchDeeplink = ref<DeeplinkTemplate | null>(null)
+const selectedEnvIndex = ref(0)
+const selectedEnv = computed<Environment | null>(
+ () => app.value?.environments[selectedEnvIndex.value] ?? null
+)
const org = computed(() => orgStore.organizations.find(o => o.id === orgId))
@@ -101,13 +105,14 @@ function closeLaunchModal() {
<div>
<h1 class="page-title">{{ app.name }}</h1>
<div class="app-envs">
- <span
- v-for="env in app.environments"
+ <button
+ v-for="(env, idx) in app.environments"
:key="env.name"
- class="env-chip"
+ :class="['env-chip', { 'env-chip--active': selectedEnvIndex === idx }]"
+ @click="selectedEnvIndex = idx"
>
{{ env.name }}
- </span>
+ </button>
</div>
</div>
<RouterLink
@@ -143,9 +148,8 @@ function closeLaunchModal() {
:deeplink="deeplink"
:orgId="orgId"
:appId="appId"
+ :environment="selectedEnv"
@launch="handleLaunchDeeplink"
- @edit="handleEditDeeplink"
- @delete="handleDeleteDeeplink"
/>
</div>
</template>
@@ -154,10 +158,13 @@ function closeLaunchModal() {
<!-- Launch modal -->
<Teleport to="body">
<LaunchModal
- v-if="launchDeeplink && app"
+ v-if="launchDeeplink && app && selectedEnv"
:deeplink="launchDeeplink"
:app="app"
+ :environment="selectedEnv"
@close="closeLaunchModal"
+ @edit="dl => { closeLaunchModal(); handleEditDeeplink(dl) }"
+ @delete="id => { closeLaunchModal(); handleDeleteDeeplink(id) }"
/>
</Teleport>
</AppLayout>
@@ -218,11 +225,25 @@ function closeLaunchModal() {
.env-chip {
font-size: 11px;
font-weight: 600;
- padding: 2px 8px;
+ padding: 2px 10px;
border-radius: 999px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
+}
+
+.env-chip:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.env-chip--active,
+.env-chip--active:hover {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+ color: #fff;
}
.deeplinks-list {
diff --git a/src/views/DebugView.vue b/src/views/DebugView.vue
new file mode 100644
index 0000000..652f6ad
--- /dev/null
+++ b/src/views/DebugView.vue
@@ -0,0 +1,254 @@
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { useLogsStore, type LogLevel } from '@/stores/logs'
+import AppLayout from '@/components/AppLayout.vue'
+
+const logsStore = useLogsStore()
+
+const activeFilter = ref<LogLevel | 'all'>('all')
+
+const filters: { label: string; value: LogLevel | 'all' }[] = [
+ { label: 'All', value: 'all' },
+ { label: 'Log', value: 'log' },
+ { label: 'Info', value: 'info' },
+ { label: 'Warn', value: 'warn' },
+ { label: 'Error', value: 'error' },
+]
+
+const filtered = computed(() =>
+ activeFilter.value === 'all'
+ ? logsStore.entries
+ : logsStore.entries.filter((e) => e.level === activeFilter.value),
+)
+
+function formatTime(iso: string): string {
+ try {
+ return new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' }).format(new Date(iso))
+ } catch {
+ return iso
+ }
+}
+
+function clearLogs() {
+ if (confirm('Clear all logs?')) {
+ logsStore.clear()
+ }
+}
+</script>
+
+<template>
+ <AppLayout>
+ <div class="debug-view">
+ <div class="content-header">
+ <div>
+ <h1 class="page-title">Debug Logs</h1>
+ <div class="page-subtitle">{{ logsStore.entries.length }} entries captured this session</div>
+ </div>
+ <button
+ v-if="logsStore.entries.length > 0"
+ class="btn btn-danger btn-sm"
+ @click="clearLogs"
+ >
+ Clear
+ </button>
+ </div>
+
+ <div class="toolbar">
+ <button
+ v-for="f in filters"
+ :key="f.value"
+ :class="['filter-btn', f.value, { active: activeFilter === f.value }]"
+ @click="activeFilter = f.value"
+ >
+ {{ f.label }}
+ </button>
+ </div>
+
+ <div v-if="filtered.length === 0" class="empty-state">
+ <div class="empty-state-icon">🪵</div>
+ <h3>No logs</h3>
+ <p>Console output will be captured here.</p>
+ </div>
+
+ <div v-else class="log-list">
+ <div
+ v-for="entry in filtered"
+ :key="entry.id"
+ :class="['log-entry', `log-entry--${entry.level}`]"
+ >
+ <span class="log-time">{{ formatTime(entry.timestamp) }}</span>
+ <span :class="['log-level', `log-level--${entry.level}`]">{{ entry.level }}</span>
+ <pre class="log-message">{{ entry.message }}</pre>
+ </div>
+ </div>
+ </div>
+ </AppLayout>
+</template>
+
+<style scoped>
+.debug-view {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.content-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 24px 24px 16px;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.page-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--color-text);
+ margin-bottom: 2px;
+}
+
+.page-subtitle {
+ font-size: 13px;
+ color: var(--color-text-muted);
+}
+
+.toolbar {
+ display: flex;
+ gap: 6px;
+ padding: 0 24px 12px;
+ flex-shrink: 0;
+}
+
+.filter-btn {
+ font-size: 12px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.filter-btn:hover,
+.filter-btn.active {
+ background: var(--color-surface-raised);
+ color: var(--color-text);
+ border-color: var(--color-text-muted);
+}
+
+.filter-btn.warn.active { color: #f59e0b; border-color: #f59e0b80; background: #f59e0b15; }
+.filter-btn.error.active { color: #ef4444; border-color: #ef444480; background: #ef444415; }
+.filter-btn.info.active { color: #3b82f6; border-color: #3b82f680; background: #3b82f615; }
+
+.empty-state {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--color-text-muted);
+ padding: 40px;
+}
+
+.empty-state-icon {
+ font-size: 40px;
+ margin-bottom: 4px;
+}
+
+.empty-state h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-text);
+ margin: 0;
+}
+
+.empty-state p {
+ font-size: 13px;
+ margin: 0;
+}
+
+.log-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 24px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.log-entry {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ border-left: 3px solid transparent;
+ background: var(--color-surface);
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ font-size: 12px;
+}
+
+.log-entry--warn {
+ border-left-color: #f59e0b;
+ background: #f59e0b08;
+}
+
+.log-entry--error {
+ border-left-color: #ef4444;
+ background: #ef444408;
+}
+
+.log-entry--info {
+ border-left-color: #3b82f6;
+ background: #3b82f608;
+}
+
+.log-time {
+ color: var(--color-text-muted);
+ font-size: 11px;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.log-level {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 1px 6px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.log-level--log { color: var(--color-text-muted); background: var(--color-surface-raised); }
+.log-level--info { color: #3b82f6; background: #3b82f615; }
+.log-level--warn { color: #f59e0b; background: #f59e0b15; }
+.log-level--error { color: #ef4444; background: #ef444415; }
+
+.log-message {
+ flex: 1;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--color-text);
+ font-family: inherit;
+ font-size: inherit;
+ line-height: 1.5;
+}
+
+@media (max-width: 600px) {
+ .content-header {
+ padding: 16px 16px 12px;
+ }
+ .toolbar {
+ padding: 0 16px 10px;
+ }
+ .log-list {
+ padding: 0 16px 16px;
+ }
+}
+</style>
diff --git a/vite.config.ts b/vite.config.ts
index 3923f95..cfaf233 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,6 +10,7 @@ export default defineConfig({
},
},
server: {
+ host: true,
proxy: {
'/api': {
target: 'http://localhost:3200',