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:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user