feat: initial schemas-app implementation

- Add Nuxt 4 application with Vuetify UI framework
- Implement GraphQL schema registry management interface
- Add Apollo Client integration with Auth0 authentication
- Create organization and API key management
- Add schema and ref browsing capabilities
- Implement organization switcher for multi-org users
- Add delete functionality for organizations and API keys
- Create Kubernetes deployment descriptors
- Add Docker configuration with nginx

Features:
- Dashboard with organization overview
- Schema browsing by ref with supergraph viewing
- Ref management with schema details
- Settings page for organizations and API keys
- User list per organization with provider icons
- Admin-only organization creation
- Delete confirmations with warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 16:42:35 +01:00
commit 072e1b10f1
41 changed files with 25557 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
<template>
<div>
<v-row>
<v-col cols="12">
<v-btn
prepend-icon="mdi-arrow-left"
variant="text"
@click="navigateTo('/schemas')"
>
Back to Schemas
</v-btn>
</v-col>
</v-row>
<v-row v-if="schema">
<v-col cols="12">
<h1 class="text-h3 mb-2">{{ schema.service }}</h1>
<v-chip class="mr-2">{{ schema.ref }}</v-chip>
<v-chip color="success">Active</v-chip>
</v-col>
</v-row>
<v-row v-if="schema">
<v-col cols="12" md="6">
<v-card>
<v-card-title>Details</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<v-list-item-title>Service</v-list-item-title>
<v-list-item-subtitle>{{ schema.service }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Ref</v-list-item-title>
<v-list-item-subtitle>{{ schema.ref }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>URL</v-list-item-title>
<v-list-item-subtitle>{{ schema.url }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="schema.wsUrl">
<v-list-item-title>WebSocket URL</v-list-item-title>
<v-list-item-subtitle>{{ schema.wsUrl }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Last Updated</v-list-item-title>
<v-list-item-subtitle>{{ schema.updatedAt }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Updated By</v-list-item-title>
<v-list-item-subtitle>{{ schema.updatedBy }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Actions</v-card-title>
<v-card-text>
<v-btn
block
prepend-icon="mdi-download"
variant="outlined"
class="mb-2"
@click="downloadSchema"
>
Download SDL
</v-btn>
<v-btn
block
prepend-icon="mdi-content-copy"
variant="outlined"
class="mb-2"
@click="copyToClipboard"
>
Copy SDL
</v-btn>
<v-btn
block
prepend-icon="mdi-graph"
variant="outlined"
color="primary"
>
View Federation Graph
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-if="schema">
<v-col cols="12">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>Schema Definition (SDL)</span>
<v-btn
icon="mdi-content-copy"
variant="text"
size="small"
@click="copyToClipboard"
/>
</v-card-title>
<v-card-text>
<pre class="sdl-viewer"><code>{{ schema.sdl }}</code></pre>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-if="!schema && !loading">
<v-col cols="12" class="text-center py-12">
<v-icon icon="mdi-alert-circle" size="64" class="mb-4 text-warning" />
<p class="text-h6">Schema not found</p>
</v-col>
</v-row>
<v-snackbar v-model="snackbar" :timeout="2000">
{{ snackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const _config = useRuntimeConfig()
const schema = ref<any>(null)
const loading = ref(true)
const snackbar = ref(false)
const snackbarText = ref('')
const downloadSchema = () => {
if (!schema.value) return
const blob = new Blob([schema.value.sdl], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${schema.value.service}-${schema.value.ref}.graphql`
a.click()
URL.revokeObjectURL(url)
snackbarText.value = 'Schema downloaded'
snackbar.value = true
}
const copyToClipboard = async () => {
if (!schema.value) return
try {
await navigator.clipboard.writeText(schema.value.sdl)
snackbarText.value = 'SDL copied to clipboard'
snackbar.value = true
} catch (_err) {
snackbarText.value = 'Failed to copy'
snackbar.value = true
}
}
// TODO: Fetch actual data from the GraphQL API
onMounted(() => {
// Mock data for now
setTimeout(() => {
schema.value = {
id: route.params.id,
service: 'users-service',
ref: 'production',
url: 'http://users.example.com/graphql',
wsUrl: 'ws://users.example.com/graphql',
updatedAt: '2024-11-21 19:30:00',
updatedBy: 'john.doe@example.com',
sdl: `type User @key(fields: "id") {
id: ID!
username: String!
email: String!
createdAt: DateTime!
}
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
input CreateUserInput {
username: String!
email: String!
}
input UpdateUserInput {
username: String
email: String
}
scalar DateTime`,
}
loading.value = false
}, 500)
})
</script>
<style scoped>
.sdl-viewer {
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
overflow-x: auto;
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<template>
<div>
<v-row>
<v-col cols="12">
<h1 class="text-h3 mb-4">Published Schemas</h1>
</v-col>
</v-row>
<v-row v-if="!auth0.isAuthenticated.value">
<v-col cols="12">
<v-alert type="info" variant="tonal">
Please log in to view published schemas.
</v-alert>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" md="3">
<v-select
v-model="selectedRef"
:items="refs"
label="Select Ref"
prepend-icon="mdi-source-branch"
variant="outlined"
:disabled="!selectedOrganization"
/>
</v-col>
<v-col cols="12" md="9">
<v-text-field
v-model="search"
label="Search schemas"
prepend-icon="mdi-magnify"
variant="outlined"
clearable
/>
</v-col>
</v-row>
<v-row v-if="auth0.isAuthenticated.value && latestSchema.loading.value">
<v-col cols="12" class="text-center">
<v-progress-circular indeterminate size="64" />
<p class="mt-4">Loading schemas for {{ selectedRef }}...</p>
</v-col>
</v-row>
<v-row v-else-if="auth0.isAuthenticated.value && latestSchema.error.value">
<v-col cols="12">
<v-alert type="error" variant="tonal">
Error loading schemas: {{ latestSchema.error.value.message }}
</v-alert>
</v-col>
</v-row>
<v-row v-else-if="auth0.isAuthenticated.value">
<v-col
v-for="schema in filteredSchemas"
:key="schema.id"
cols="12"
md="6"
lg="4"
>
<v-card hover @click="navigateTo(`/schemas/${schema.id}`)">
<v-card-title>
<v-icon icon="mdi-graphql" class="mr-2" />
{{ schema.service }}
</v-card-title>
<v-card-subtitle>{{ schema.ref }}</v-card-subtitle>
<v-card-text>
<div class="mb-2">
<strong>URL:</strong> {{ schema.url }}
</div>
<div class="text-caption text-grey">
Updated: {{ schema.updatedAt }}
</div>
</v-card-text>
<v-card-actions>
<v-btn variant="text" color="primary">
View Schema
</v-btn>
<v-spacer />
<v-chip size="small" color="success">Active</v-chip>
</v-card-actions>
</v-card>
</v-col>
<v-col v-if="filteredSchemas.length === 0" cols="12" class="text-center py-12">
<v-icon icon="mdi-graphql" size="64" class="mb-4 text-grey" />
<p class="text-h6 text-grey">No schemas found</p>
<p class="text-grey">{{ search ? 'Try a different search term' : 'Publish your first schema to get started' }}</p>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import { useAuth0 } from '@auth0/auth0-vue'
import { watch } from 'vue'
import { useOrganizationSelector } from '~/composables/useOrganizationSelector'
import {
useLatestSchemaQuery,
} from '~/graphql/generated'
const auth0 = useAuth0()
const { selectedOrganization } = useOrganizationSelector()
const selectedRef = ref('production')
const search = ref('')
// Get available refs from the selected organization
const refs = computed(() => {
if (!selectedOrganization.value) return ['production', 'staging', 'development']
const allRefs = new Set<string>()
selectedOrganization.value.apiKeys?.forEach(key => {
key.refs?.forEach(ref => allRefs.add(ref))
})
return Array.from(allRefs).length > 0 ? Array.from(allRefs) : ['production', 'staging', 'development']
})
// Fetch schema for selected ref
const latestSchema = useLatestSchemaQuery(() => ({
ref: selectedRef.value,
}), () => ({
skip: !auth0.isAuthenticated.value || !selectedRef.value,
}))
const schemas = computed(() => {
if (!latestSchema.result.value?.latestSchema?.subGraphs) return []
return latestSchema.result.value.latestSchema.subGraphs.map(subgraph => ({
id: subgraph.id,
service: subgraph.service,
ref: selectedRef.value,
url: subgraph.url || 'N/A',
wsUrl: subgraph.wsUrl,
updatedAt: new Date(subgraph.changedAt).toLocaleString(),
changedBy: subgraph.changedBy,
}))
})
const filteredSchemas = computed(() => {
let filtered = schemas.value
if (search.value) {
const searchLower = search.value.toLowerCase()
filtered = filtered.filter(s =>
s.service.toLowerCase().includes(searchLower) ||
s.url.toLowerCase().includes(searchLower),
)
}
return filtered
})
// Watch for ref changes and ensure the first ref is selected
watch(refs, (newRefs) => {
if (newRefs.length > 0 && !newRefs.includes(selectedRef.value)) {
selectedRef.value = newRefs[0]
}
}, { immediate: true })
</script>