├── ui ├── src │ ├── boot │ │ ├── .gitkeep │ │ ├── axios.ts │ │ └── i18n.ts │ ├── css │ │ ├── app.scss │ │ └── quasar.variables.scss │ ├── helper │ │ ├── objects.ts │ │ └── functions.ts │ ├── App.vue │ ├── i18n │ │ ├── index.ts │ │ └── langs │ │ │ ├── en-US.json │ │ │ └── de-DE.json │ ├── client │ │ ├── models.ts │ │ └── backend.ts │ ├── env.d.ts │ ├── components │ │ ├── DateField.vue │ │ ├── NodeLink.vue │ │ ├── StatusButton.vue │ │ ├── EnvironmentSelector.vue │ │ ├── MetricsTable.vue │ │ ├── ReportStatus.vue │ │ ├── EventsTable.vue │ │ ├── EventCountBlock.vue │ │ ├── JsonViewDialog.vue │ │ ├── DashboardItem.vue │ │ ├── ReportSummaryTable.vue │ │ ├── ReportLogsTable.vue │ │ ├── NodeTable.vue │ │ └── QueryExecuter.vue │ ├── stores │ │ ├── settings.ts │ │ └── index.ts │ ├── puppet │ │ ├── models │ │ │ ├── puppet-event-count.ts │ │ │ ├── puppet-event.ts │ │ │ ├── puppet-node.ts │ │ │ └── puppet-report.ts │ │ ├── models.ts │ │ └── query-builder.ts │ ├── router │ │ ├── index.ts │ │ └── routes.ts │ ├── pages │ │ ├── node │ │ │ ├── NodeOverviewPage.vue │ │ │ └── NodeDetailPage.vue │ │ ├── fact │ │ │ ├── FactOverviewPage.vue │ │ │ └── FactDetailPage.vue │ │ ├── views │ │ │ └── PredefinedViewResultPage.vue │ │ ├── report │ │ │ ├── ReportDetailPage.vue │ │ │ └── ReportOverviewPage.vue │ │ ├── DashboardPage.vue │ │ └── QueryPage.vue │ ├── assets │ │ └── quasar-logo-vertical.svg │ └── layouts │ │ └── MainLayout.vue ├── .prettierrc ├── tsconfig.json ├── public │ ├── logo.png │ ├── favicon.ico │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ └── favicon-128x128.png ├── tsconfig.vue-tsc.json ├── .editorconfig ├── .npmrc ├── .vscode │ ├── extensions.json │ └── settings.json ├── README.md ├── .gitignore ├── postcss.config.js ├── index.html ├── package.json ├── eslint.config.js └── quasar.config.js ├── .gitignore ├── screenshots ├── reports.png ├── node_detail.png ├── query_history.png └── query_execution.png ├── uiserve.go ├── model ├── fact.go ├── event_count.go ├── view.go ├── metric.go └── node.go ├── Containerfile ├── Makefile ├── handler ├── core.go ├── pdb.go └── view.go ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .goreleaser.yml ├── .air.toml ├── DEVELOPMENT.md ├── README.md ├── go.mod ├── main.go ├── config └── config.go ├── CONFIGURATION.md ├── puppetdb └── client.go ├── go.sum └── LICENSE /ui/src/boot/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/css/app.scss: -------------------------------------------------------------------------------- 1 | // app global css in SCSS form 2 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true 4 | } 5 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.quasar/tsconfig.json", 3 | } 4 | -------------------------------------------------------------------------------- /ui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | .idea/ 3 | openvoxview 4 | /config.yaml 5 | /.direnv/ 6 | /ui/.direnv/ 7 | -------------------------------------------------------------------------------- /screenshots/reports.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/screenshots/reports.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/favicon.ico -------------------------------------------------------------------------------- /uiserve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "embed" 4 | 5 | //go:embed ui/dist/spa 6 | var uiFS embed.FS 7 | -------------------------------------------------------------------------------- /screenshots/node_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/screenshots/node_detail.png -------------------------------------------------------------------------------- /screenshots/query_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/screenshots/query_history.png -------------------------------------------------------------------------------- /screenshots/query_execution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/screenshots/query_execution.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpupuli/openvoxview/main/ui/public/icons/favicon-128x128.png -------------------------------------------------------------------------------- /ui/src/helper/objects.ts: -------------------------------------------------------------------------------- 1 | const emptyPagination = { 2 | page: 0, 3 | rowsPerPage: 0, 4 | } 5 | 6 | export {emptyPagination}; 7 | -------------------------------------------------------------------------------- /ui/tsconfig.vue-tsc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "skipLibCheck": true 5 | } 6 | } -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /ui/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import enUS from './langs/en-US.json'; 2 | import deDE from './langs/de-DE.json'; 3 | 4 | export default { 5 | 'en-US': enUS, 6 | 'de-DE': deDE, 7 | }; 8 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /model/fact.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Fact struct { 4 | Certname string `json:"certname"` 5 | Name string `json:"name"` 6 | Environment string `json:"environment"` 7 | Value any `json:"value"` 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/client/models.ts: -------------------------------------------------------------------------------- 1 | export interface BaseResponse { 2 | Data: T; 3 | } 4 | 5 | export interface ErrorResponse { 6 | Error: string 7 | } 8 | 9 | export interface ApiVersion { 10 | Version: string; 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | NODE_ENV: string; 4 | VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined; 5 | VUE_ROUTER_BASE: string | undefined; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/.npmrc: -------------------------------------------------------------------------------- 1 | # pnpm-related options 2 | shamefully-hoist=true 3 | strict-peer-dependencies=false 4 | # to get the latest compatible packages when creating the project https://github.com/pnpm/pnpm/issues/6463 5 | resolution-mode=highest 6 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 AS build 2 | 3 | RUN apk add --no-cache go yarn make 4 | 5 | WORKDIR /build 6 | COPY . /build 7 | 8 | ARG VUE_APP_COMMIT=dirty 9 | ENV VUE_APP_COMMIT=${VUE_APP_COMMIT} 10 | 11 | RUN make backend 12 | 13 | FROM alpine:3.21 14 | ENV PORT=8080 15 | COPY --from=build /build/openvoxview /openvoxview 16 | 17 | ENTRYPOINT /openvoxview 18 | -------------------------------------------------------------------------------- /model/event_count.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type EventCount struct { 4 | Failures int `json:"failures"` 5 | Skips int `json:"skips"` 6 | Successes int `json:"successes"` 7 | Noops int `json:"noops"` 8 | SubjectType string `json:"subject_type"` 9 | Subject struct { 10 | Title string `json:"title"` 11 | } `json:"subject"` 12 | } 13 | -------------------------------------------------------------------------------- /model/view.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type View struct { 4 | Name string `mapstructure:"name"` 5 | Facts []ViewFact `mapstructure:"facts"` 6 | } 7 | 8 | type ViewFact struct { 9 | Name string `mapstructure:"name"` 10 | Fact string `mapstructure:"fact"` 11 | Renderer string `mapstructure:"renderer"` 12 | } 13 | 14 | type ViewResult struct { 15 | View View 16 | Data any 17 | } 18 | -------------------------------------------------------------------------------- /ui/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "editorconfig.editorconfig", 6 | "vue.volar", 7 | "wayou.vscode-todo-highlight" 8 | ], 9 | "unwantedRecommendations": [ 10 | "octref.vetur", 11 | "hookyqr.beautify", 12 | "dbaeumer.jshint", 13 | "ms-vscode.vscode-typescript-tslint-plugin" 14 | ] 15 | } -------------------------------------------------------------------------------- /ui/src/components/DateField.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME ?= openvoxview 2 | .PHONY: ui backend 3 | 4 | ui: 5 | cd ui; yarn install; yarn build 6 | 7 | backend: ui 8 | go build -o $(BINARY_NAME) 9 | 10 | develop-frontend: ui 11 | cd ui; VUE_APP_BACKEND_BASE_ADDRESS=http://localhost:5000 yarn dev 12 | 13 | develop-backend: ui 14 | air 15 | 16 | develop-backend-crafty: ui 17 | PUPPETDB_TLS_IGNORE=true PUPPETDB_PORT=8081 PUPPETDB_TLS=true air 18 | 19 | all: backend 20 | 21 | -------------------------------------------------------------------------------- /ui/src/components/NodeLink.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /ui/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.bracketPairColorization.enabled": true, 3 | "editor.guides.bracketPairs": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.codeActionsOnSave": [ 7 | "source.fixAll.eslint" 8 | ], 9 | "eslint.validate": [ 10 | "javascript", 11 | "javascriptreact", 12 | "typescript", 13 | "vue" 14 | ], 15 | "typescript.tsdk": "node_modules/typescript/lib" 16 | } -------------------------------------------------------------------------------- /handler/core.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | func baseResponse() map[string]any { 10 | return gin.H{ 11 | "Timestamp": time.Now().Unix(), 12 | } 13 | } 14 | 15 | func NewErrorResponse(err error) map[string]any { 16 | resp := baseResponse() 17 | resp["Error"] = err.Error() 18 | 19 | return resp 20 | } 21 | 22 | func NewSuccessResponse(data interface{}) map[string]any { 23 | resp := baseResponse() 24 | resp["Data"] = data 25 | 26 | return resp 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/stores/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export const useSettingsStore = defineStore( 5 | 'settings', 6 | () => { 7 | const environment = ref(); 8 | const darkMode = ref(false); 9 | 10 | function hasEnvironment() { 11 | if (!environment.value) return false; 12 | return environment.value != '*' && environment.value != ''; 13 | } 14 | 15 | return { environment, darkMode, hasEnvironment }; 16 | }, 17 | { persist: true } 18 | ); 19 | -------------------------------------------------------------------------------- /ui/src/puppet/models/puppet-event-count.ts: -------------------------------------------------------------------------------- 1 | import { autoImplement } from 'src/helper/functions'; 2 | 3 | type PuppetEventCountSubject = { 4 | title: string; 5 | }; 6 | 7 | export interface ApiPuppetEventCount { 8 | failures: number; 9 | skips: number; 10 | successes: number; 11 | noops: number; 12 | subject_type: string; 13 | subject: PuppetEventCountSubject; 14 | } 15 | 16 | export class PuppetEventCount extends autoImplement() { 17 | static fromApi(apiItem: ApiPuppetEventCount) : PuppetEventCount { 18 | return new PuppetEventCount(apiItem); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # raise PRs for go updates 4 | - package-ecosystem: gomod 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | time: "13:00" 9 | open-pull-requests-limit: 10 10 | 11 | # Maintain dependencies for GitHub Actions 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: daily 16 | time: "13:00" 17 | open-pull-requests-limit: 10 18 | 19 | # Check for Yarn.lock 20 | - package-ecosystem: "npm" 21 | directory: "/ui/" 22 | schedule: 23 | interval: daily 24 | time: "13:00" 25 | open-pull-requests-limit: 10 26 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # OpenVox View UI (openvoxview-ui) 2 | 3 | UI for openvoxview 4 | 5 | ## Install the dependencies 6 | ```bash 7 | yarn 8 | # or 9 | npm install 10 | ``` 11 | 12 | ### Start the app in development mode (hot-code reloading, error reporting, etc.) 13 | ```bash 14 | quasar dev 15 | ``` 16 | 17 | 18 | ### Lint the files 19 | ```bash 20 | yarn lint 21 | # or 22 | npm run lint 23 | ``` 24 | 25 | 26 | ### Format the files 27 | ```bash 28 | yarn format 29 | # or 30 | npm run format 31 | ``` 32 | 33 | 34 | 35 | ### Build the app for production 36 | ```bash 37 | quasar build 38 | ``` 39 | 40 | ### Customize the configuration 41 | See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js). 42 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules 4 | 5 | # Quasar core related directories 6 | .quasar 7 | /dist 8 | /quasar.config.*.temporary.compiled* 9 | 10 | # Cordova related directories and files 11 | /src-cordova/node_modules 12 | /src-cordova/platforms 13 | /src-cordova/plugins 14 | /src-cordova/www 15 | 16 | # Capacitor related directories and files 17 | /src-capacitor/www 18 | /src-capacitor/node_modules 19 | 20 | # BEX related directories and files 21 | /src-bex/www 22 | /src-bex/js/core 23 | 24 | # Log files 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # Editor directories and files 30 | .idea 31 | *.suo 32 | *.ntvs* 33 | *.njsproj 34 | *.sln 35 | 36 | # local .env files 37 | .env.local* 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | concurrency: 10 | group: ${{ github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | Build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | with: 23 | fetch-depth: 0 24 | 25 | - uses: actions/setup-node@v6 26 | with: 27 | node-version: 20 28 | - run: | 29 | npm install -g yarn 30 | - name: Set up Go 31 | uses: actions/setup-go@v6 32 | with: 33 | go-version: "1.23" 34 | 35 | - name: Build 36 | run: make backend 37 | -------------------------------------------------------------------------------- /model/metric.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Metric struct { 4 | Request struct { 5 | Mbean string `json:"mbean"` 6 | Type string `json:"type"` 7 | } `json:"request"` 8 | Value struct { 9 | Value any `json:"Value"` 10 | } `json:"value"` 11 | Timestamp uint `json:"timestamp"` 12 | Status uint `json:"status"` 13 | } 14 | 15 | type MetricInfoAttr struct { 16 | Rw bool 17 | Type string 18 | Desc string 19 | } 20 | 21 | type MetricInfoOp struct { 22 | Args []any 23 | Ret string 24 | Desc string 25 | } 26 | 27 | type MetricInfo struct { 28 | Op map[string]MetricInfoOp 29 | Attr map[string]MetricInfoAttr 30 | Desc string 31 | Class string 32 | } 33 | 34 | type MetricList struct { 35 | Request struct { 36 | Type string 37 | } 38 | Value map[string]MetricInfo 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/components/StatusButton.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /ui/src/puppet/models/puppet-event.ts: -------------------------------------------------------------------------------- 1 | import { autoImplement } from 'src/helper/functions'; 2 | 3 | export interface ApiPuppetEvent { 4 | certname: string; 5 | configuration_version: string; 6 | containing_class: string; 7 | containment_path: string[]; 8 | corrective_change: null; 9 | environment: string; 10 | file: string; 11 | line: number; 12 | message: string; 13 | name: string; 14 | new_value: unknown; 15 | old_value: unknown; 16 | property: null; 17 | report: string; 18 | report_receive_time: Date; 19 | resource_title: string; 20 | resource_type: string; 21 | run_end_time: Date; 22 | run_start_time: Date; 23 | status: string; 24 | timestamp: Date; 25 | } 26 | 27 | export class PuppetEvent extends autoImplement() { 28 | static fromApi(apiItem: ApiPuppetEvent) : PuppetEvent { 29 | return new PuppetEvent(apiItem); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/css/quasar.variables.scss: -------------------------------------------------------------------------------- 1 | // Quasar SCSS (& Sass) Variables 2 | // -------------------------------------------------- 3 | // To customize the look and feel of this app, you can override 4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files. 5 | 6 | // Check documentation for full list of Quasar variables 7 | 8 | // Your own variables (that are declared here) and Quasar's own 9 | // ones will be available out of the box in your .vue/.scss/.sass files 10 | 11 | // It's highly recommended to change the default colors 12 | // to match your app's branding. 13 | // Tip: Use the "Theme Builder" on Quasar's documentation website. 14 | 15 | $primary : #1976D2; 16 | $secondary : #26A69A; 17 | $accent : #9C27B0; 18 | 19 | $dark : #1D1D1D; 20 | $dark-page : #121212; 21 | 22 | $positive : #21BA45; 23 | $negative : #C10015; 24 | $info : #31CCEC; 25 | $warning : #F2C037; 26 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | import autoprefixer from 'autoprefixer' 2 | // import rtlcss from 'postcss-rtlcss' 3 | 4 | export default { 5 | plugins: [ 6 | // https://github.com/postcss/autoprefixer 7 | autoprefixer({ 8 | overrideBrowserslist: [ 9 | 'last 4 Chrome versions', 10 | 'last 4 Firefox versions', 11 | 'last 4 Edge versions', 12 | 'last 4 Safari versions', 13 | 'last 4 Android versions', 14 | 'last 4 ChromeAndroid versions', 15 | 'last 4 FirefoxAndroid versions', 16 | 'last 4 iOS versions' 17 | ] 18 | }), 19 | 20 | // https://github.com/elchininet/postcss-rtlcss 21 | // If you want to support RTL css, then 22 | // 1. yarn/pnpm/bun/npm install postcss-rtlcss 23 | // 2. optionally set quasar.config.js > framework > lang to an RTL language 24 | // 3. uncomment the following line (and its import statement above): 25 | // rtlcss() 26 | ] 27 | } -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= productName %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /ui/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from '#q-app/wrappers' 2 | import { createPinia } from 'pinia' 3 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; 4 | 5 | /* 6 | * When adding new properties to stores, you should also 7 | * extend the `PiniaCustomProperties` interface. 8 | * @see https://pinia.vuejs.org/core-concepts/plugins.html#typing-new-store-properties 9 | */ 10 | declare module 'pinia' { 11 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 12 | export interface PiniaCustomProperties { 13 | // add your custom properties here, if any 14 | } 15 | } 16 | 17 | /* 18 | * If not building with SSR mode, you can 19 | * directly export the Store instantiation; 20 | * 21 | * The function below can be async too; either use 22 | * async/await or return a Promise which resolves 23 | * with the Store instance. 24 | */ 25 | 26 | export default defineStore((/* { ssrContext } */) => { 27 | const pinia = createPinia() 28 | 29 | pinia.use(piniaPluginPersistedstate) 30 | 31 | return pinia 32 | }) 33 | -------------------------------------------------------------------------------- /model/node.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Node struct { 6 | Name string `json:"certname"` 7 | Deactivated time.Time `json:"deactivated"` 8 | Expired bool `json:"expired"` 9 | ReportTimestamp time.Time `json:"report_timestamp"` 10 | CatalogTimestamp time.Time `json:"catalog_timestamp"` 11 | FactsTimestamp time.Time `json:"facts_timestamp"` 12 | LatestReportStatus string `json:"latest_report_status"` 13 | Unreported string `json:"unreported"` 14 | ReportEnvironment string `json:"report_environment"` 15 | CatalogEnvironment string `json:"catalog_environment"` 16 | FactsEnvironment string `json:"facts_environment"` 17 | LatestReportHash string `json:"latest_report_hash"` 18 | CachedCatalogStatus string `json:"cached_catalog_status"` 19 | Events EventCount `json:"events"` 20 | } 21 | 22 | func NodeFromData(nodeData map[string]interface{}, eventData interface{}) Node { 23 | return Node{ 24 | Name: nodeData["certname"].(string), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | - make ui 5 | archives: 6 | - id: openvoxview 7 | builds: 8 | - id: openvoxview 9 | ldflags: -s -w -X 'main.VERSION={{.Version}}' -X 'main.COMMIT={{.Commit}}' 10 | env: 11 | - CGO_ENABLED=0 12 | goos: 13 | - linux 14 | - freebsd 15 | goarch: 16 | - amd64 17 | - arm 18 | - arm64 19 | goarm: 20 | - "6" 21 | - "7" 22 | nfpms: 23 | - vendor: Vox Pupuli 24 | homepage: https://github.com/voxpupuli/openvoxview 25 | maintainer: Vox Pupuli 26 | description: |- 27 | PuppetDB Dashboard 28 | license: Apache 2.0 29 | formats: 30 | - deb 31 | - rpm 32 | release: 33 | github: 34 | owner: voxpupuli 35 | name: openvoxview 36 | name_template: "Release v{{.Version}}" 37 | prerelease: auto 38 | checksum: 39 | name_template: 'checksums.txt' 40 | snapshot: 41 | name_template: "{{ .Tag }}-next" 42 | changelog: 43 | sort: asc 44 | filters: 45 | exclude: 46 | - '^docs:' 47 | - '^test:' 48 | -------------------------------------------------------------------------------- /ui/src/components/EnvironmentSelector.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [proxy] 45 | app_port = 0 46 | enabled = false 47 | proxy_port = 0 48 | 49 | [screen] 50 | clear_on_rebuild = false 51 | keep_scroll = true 52 | -------------------------------------------------------------------------------- /ui/src/components/MetricsTable.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 37 | 38 | 41 | -------------------------------------------------------------------------------- /ui/src/components/ReportStatus.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /ui/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { defineRouter } from '#q-app/wrappers'; 2 | import { 3 | createMemoryHistory, 4 | createRouter, 5 | createWebHashHistory, 6 | createWebHistory, 7 | } from 'vue-router'; 8 | 9 | import routes from './routes'; 10 | 11 | /* 12 | * If not building with SSR mode, you can 13 | * directly export the Router instantiation; 14 | * 15 | * The function below can be async too; either use 16 | * async/await or return a Promise which resolves 17 | * with the Router instance. 18 | */ 19 | 20 | export default defineRouter(function (/* { store, ssrContext } */) { 21 | const createHistory = process.env.SERVER 22 | ? createMemoryHistory 23 | : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory); 24 | 25 | const Router = createRouter({ 26 | scrollBehavior: () => ({ left: 0, top: 0 }), 27 | routes, 28 | 29 | // Leave this as is and make changes in quasar.conf.js instead! 30 | // quasar.conf.js -> build -> vueRouterMode 31 | // quasar.conf.js -> build -> publicPath 32 | history: createHistory(process.env.VUE_ROUTER_BASE), 33 | }); 34 | 35 | return Router; 36 | }); 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: actions/setup-node@v6 21 | with: 22 | node-version: 20 23 | - run: | 24 | npm install -g yarn 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | 28 | - name: Docker Login 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Set up Go 36 | uses: actions/setup-go@v6 37 | with: 38 | go-version: "1.23" 39 | 40 | - name: Run GoReleaser 41 | uses: goreleaser/goreleaser-action@v6 42 | with: 43 | version: latest 44 | args: release --clean 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /ui/src/components/EventsTable.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 42 | 43 | 46 | -------------------------------------------------------------------------------- /ui/src/components/EventCountBlock.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 30 | 31 | 37 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | OpenVoxView has two components, a small golang backend and a Vue 3 (Quasar) frontend. 4 | 5 | ## Requirements 6 | - golang >= 1.23 7 | - yarn 8 | - node >=20 <=24 9 | 10 | ## Prerequisite (OpenVoxDB) 11 | 12 | You can connect directly to an OpenVoxDB if you can access one, 13 | but for local development the recommendation is using [Crafty](https://github.com/voxpupuli/crafty) 14 | 15 | ## Frontend development 16 | you can execute `make develop-frontend` it will execute following steps: 17 | 18 | ``` 19 | cd ui 20 | yarn install 21 | yarn build 22 | VUE_APP_BACKEND_BASE_ADDRESS=http://localhost:5000 yarn dev 23 | ``` 24 | 25 | **VUE_APP_BACKEND_BASE_ADDRESS** is an environment variable which points to the backend for the development. 26 | in production this will be automatically shipped by the backend 27 | 28 | 29 | ## Backend development 30 | 31 | For backend development you can use your favorite golang development environment and 32 | start directly with the development. 33 | 34 | You need to have the ui/dist/spa folder, it can be generated by build the frontend or it can be empty: 35 | `mkdir -p ui/dist/spa` 36 | 37 | for settings see [CONFIGURATION.md](./CONFIGURATION.md) 38 | 39 | 40 | # Releases 41 | For releases currently it only needs a git tag 42 | -------------------------------------------------------------------------------- /ui/src/components/JsonViewDialog.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ui/src/puppet/models/puppet-node.ts: -------------------------------------------------------------------------------- 1 | import { autoImplement } from 'src/helper/functions'; 2 | import { type ApiPuppetEventCount, PuppetEventCount } from 'src/puppet/models/puppet-event-count'; 3 | 4 | export interface ApiPuppetNode { 5 | cached_catalog_status: string; 6 | catalog_environment: string; 7 | catalog_timestamp: Date; 8 | certname: string; 9 | deactivated: boolean | null; 10 | expired: boolean | null; 11 | facts_environment: string; 12 | facts_timestamp: Date; 13 | latest_report_corrective_change: boolean | null; 14 | latest_report_hash: string; 15 | latest_report_job_id: string; 16 | latest_report_noop: boolean; 17 | latest_report_noop_pending: boolean; 18 | latest_report_status: string; 19 | report_environment: string; 20 | report_timestamp: Date; 21 | } 22 | 23 | export class PuppetNode extends autoImplement() { 24 | static fromApi(apiItem: ApiPuppetNode) : PuppetNode { 25 | return new PuppetNode(apiItem); 26 | } 27 | } 28 | 29 | export interface ApiPuppetNodeWithEventCount extends PuppetNode { 30 | events: ApiPuppetEventCount; 31 | } 32 | 33 | export class PuppetNodeWithEventCount extends autoImplement() { 34 | static fromApi(apiItem: ApiPuppetNodeWithEventCount) : PuppetNodeWithEventCount { 35 | return new PuppetNodeWithEventCount(apiItem); 36 | } 37 | 38 | get eventsMapped() { 39 | return PuppetEventCount.fromApi(this.events) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/components/DashboardItem.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /ui/src/boot/axios.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers'; 2 | import axios, { type AxiosInstance } from 'axios'; 3 | import { Notify } from 'quasar'; 4 | 5 | declare module 'vue' { 6 | interface ComponentCustomProperties { 7 | $axios: AxiosInstance; 8 | $api: AxiosInstance; 9 | } 10 | } 11 | 12 | 13 | const api = axios.create({ baseURL: process.env.VUE_APP_BACKEND_BASE_ADDRESS || ''}); 14 | 15 | export default defineBoot(({ app }) => { 16 | // for use inside Vue files (Options API) through this.$axios and this.$api 17 | api.interceptors.response.use(function (response) { 18 | // Any status code that lie within the range of 2xx cause this function to trigger 19 | // Do something with response data 20 | return response; 21 | }, function (error) { 22 | if (error.response) { 23 | console.log('Cached Error: ', error.response.data.Error); 24 | Notify.create({ 25 | message: error.response.data.Error, 26 | color: 'negative', 27 | multiLine: true, 28 | closeBtn: true, 29 | }) 30 | } 31 | 32 | return Promise.reject(new Error(error)); 33 | }); 34 | 35 | 36 | app.config.globalProperties.$axios = axios; 37 | // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) 38 | // so you won't necessarily have to import axios in each vue file 39 | 40 | app.config.globalProperties.$api = api; 41 | // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) 42 | // so you can easily perform requests against your app's API 43 | }); 44 | 45 | export { api }; 46 | -------------------------------------------------------------------------------- /ui/src/components/ReportSummaryTable.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 52 | 53 | 56 | -------------------------------------------------------------------------------- /ui/src/components/ReportLogsTable.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /ui/src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteRecordRaw } from 'vue-router'; 2 | 3 | const routes: RouteRecordRaw[] = [ 4 | { 5 | path: '/', 6 | redirect: { name: 'Dashboard' }, 7 | component: () => import('layouts/MainLayout.vue'), 8 | children: [ 9 | { 10 | name: 'Dashboard', 11 | path: 'dashboard', 12 | component: () => import('pages/DashboardPage.vue'), 13 | }, 14 | { 15 | name: 'Query', 16 | path: 'query', 17 | component: () => import('pages/QueryPage.vue'), 18 | }, 19 | { 20 | name: 'FactOverview', 21 | path: 'facts', 22 | component: () => import('pages/fact/FactOverviewPage.vue'), 23 | }, 24 | { 25 | name: 'FactDetail', 26 | path: 'fact/:fact', 27 | component: () => import('pages/fact/FactDetailPage.vue'), 28 | }, 29 | { 30 | name: 'NodeOverview', 31 | path: 'nodes', 32 | component: () => import('pages/node/NodeOverviewPage.vue'), 33 | }, 34 | { 35 | name: 'NodeDetail', 36 | path: 'node/:node', 37 | component: () => import('pages/node/NodeDetailPage.vue'), 38 | }, 39 | { 40 | name: 'ReportOverview', 41 | path: 'reports', 42 | component: () => import('pages/report/ReportOverviewPage.vue'), 43 | }, 44 | { 45 | name: 'ReportDetail', 46 | path: 'report/:certname/:report_hash', 47 | component: () => import('pages/report/ReportDetailPage.vue'), 48 | }, 49 | { 50 | name: 'PredefinedViewResult', 51 | path: 'view/:viewName', 52 | component: () => import('pages/views/PredefinedViewResultPage.vue'), 53 | }, 54 | ], 55 | }, 56 | ]; 57 | 58 | export default routes; 59 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openvoxview-ui", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "description": "UI for openvoxview", 6 | "productName": "OpenVox View UI", 7 | "author": "Sebastian Rakel ", 8 | "private": true, 9 | "scripts": { 10 | "lint": "eslint -c ./eslint.config.js \"./src*/**/*.{ts,js,cjs,mjs,vue}\"", 11 | "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore", 12 | "test": "echo \"No test specified\" && exit 0", 13 | "dev": "quasar dev", 14 | "build": "quasar build", 15 | "i18n": "vue-i18n-extract report --add --vueFiles './src/**/*.?(ts|vue)' --languageFiles './src/i18n/langs/*.json' --noEmptyTranslation '*'", 16 | "postinstall": "quasar prepare" 17 | }, 18 | "dependencies": { 19 | "@quasar/extras": "^1.16.4", 20 | "axios": "^1.2.1", 21 | "moment": "^2.30.1", 22 | "pinia": "^3.0.1", 23 | "pinia-plugin-persistedstate": "^4.3.0", 24 | "quasar": "^2.16.0", 25 | "vue": "^3.5.20", 26 | "vue-i18n": "^11.0.0", 27 | "vue-router": "^4.6.0", 28 | "vue3-json-viewer": "^2.4.1" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.37.0", 32 | "@intlify/unplugin-vue-i18n": "^11.0.1", 33 | "@quasar/app-vite": "^2.4.0", 34 | "@types/node": "^24.9.1", 35 | "@vue/eslint-config-prettier": "^10.2.0", 36 | "@vue/eslint-config-typescript": "^14.6.0", 37 | "autoprefixer": "^10.4.2", 38 | "eslint": "^9.38.0", 39 | "eslint-plugin-vue": "^10.5.0", 40 | "globals": "^16.4.0", 41 | "prettier": "^3.3.3", 42 | "typescript": "~5.9.2", 43 | "vite-plugin-checker": "^0.11.0", 44 | "vue-eslint-parser": "^10.2.0", 45 | "vue-i18n-extract": "^2.0.7", 46 | "vue-tsc": "^3.1.2" 47 | }, 48 | "engines": { 49 | "node": "^24 || ^22 || ^20", 50 | "npm": ">= 6.13.4", 51 | "yarn": ">= 1.21.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenVox View 2 | [![Build and Release](https://github.com/voxpupuli/openvoxview/actions/workflows/ci.yml/badge.svg)](https://github.com/voxpupuli/openvoxview/actions/workflows/ci.yml) 3 | [![Apache-2 License](https://img.shields.io/github/license/voxpupuli/openvoxview.svg)](LICENSE) 4 | 5 | Logo 6 | 7 | ## Project Status 8 | This project is currently in its beta stage and may not be suitable for production use. 9 | 10 | ## Introduction 11 | OpenVox View is a viewer for openvoxdb/puppetdb, inspired by [Puppetboard](https://github.com/voxpupuli/puppetboard). 12 | 13 | ## Features 14 | - Overview of reports 15 | - Overview of facts 16 | - Overview of nodes 17 | - Predefined views 18 | - Ability to perform multiple queries 19 | - Query history 20 | - Predefined queries 21 | 22 | ## Container 23 | You can build a container with the Containerfile 24 | 25 | ```bash 26 | podman build -t openvoxview . 27 | ``` 28 | 29 | or for Docker 30 | ```bash 31 | docker build -t openvoxview -f Containerfile . 32 | ``` 33 | 34 | ## Configuration 35 | See [CONFIGURATION.md](./CONFIGURATION.md) 36 | 37 | 38 | ## Screenshots 39 | ### Reports Overview 40 | ![Reports Overview](./screenshots/reports.png) 41 | 42 | ### Node Detail 43 | ![Node Detail](./screenshots/node_detail.png) 44 | 45 | ### Query Execution 46 | ![Query Execution](./screenshots/query_execution.png) 47 | 48 | ### Query History 49 | ![Query History](./screenshots/query_history.png) 50 | 51 | ## Contribution 52 | We welcome you to create issues or submit pull requests. Most important be excellent to each other. 53 | 54 | For more infos, see [DEVELOPMENT.md](./DEVELOPMENT.md) 55 | 56 | ## OpenVox/Puppet Module 57 | There is also a openvox module for deployment of openvoxview see [puppet-openvoxview](https://github.com/voxpupuli/puppet-openvoxview) 58 | 59 | 60 | ## Special Thanks 61 | We extend our gratitude for the remarkable work on [Puppetboard](https://github.com/voxpupuli/puppetboard). 62 | -------------------------------------------------------------------------------- /ui/src/boot/i18n.ts: -------------------------------------------------------------------------------- 1 | import { defineBoot } from '#q-app/wrappers'; 2 | import { createI18n } from 'vue-i18n'; 3 | 4 | import messages from 'src/i18n'; 5 | 6 | export type MessageLanguages = keyof typeof messages; 7 | // Type-define 'en-US' as the master schema for the resource 8 | export type MessageSchema = (typeof messages)['en-US']; 9 | 10 | // See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition 11 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 12 | declare module 'vue-i18n' { 13 | // define the locale messages schema 14 | export interface DefineLocaleMessage extends MessageSchema {} 15 | 16 | // define the datetime format schema 17 | export interface DefineDateTimeFormat {} 18 | 19 | // define the number format schema 20 | export interface DefineNumberFormat {} 21 | } 22 | /* eslint-enable @typescript-eslint/no-empty-object-type */ 23 | export default defineBoot(({ app }) => { 24 | const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({ 25 | locale: getBrowserLocale(), 26 | legacy: false, 27 | messages, 28 | }); 29 | 30 | // Set i18n instance on app 31 | app.use(i18n); 32 | }); 33 | 34 | export function getBrowserLocale(): MessageLanguages { 35 | const availableLocales: MessageLanguages[] = Object.keys(messages) as MessageLanguages[]; 36 | const fallbackLocale: MessageLanguages = 'en-US'; 37 | 38 | if (navigator.languages) { 39 | for (const lang of navigator.languages) { 40 | // Try an exact match 41 | if (availableLocales.includes(lang as MessageLanguages)) { 42 | return lang as MessageLanguages; 43 | } 44 | 45 | // Otherwise try to split language code and try to compare the first part only 46 | const langCode = lang.split('-')[0]; 47 | const match = availableLocales.find( 48 | (loc) => loc.split('-')[0] === langCode, 49 | ); 50 | if (match) { 51 | return match; 52 | } 53 | } 54 | } 55 | 56 | return fallbackLocale; 57 | } 58 | -------------------------------------------------------------------------------- /ui/src/puppet/models.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { autoImplement } from 'src/helper/functions'; 4 | 5 | export interface PuppetEnvironment { 6 | name: string; 7 | } 8 | 9 | export interface ApiPuppetFact { 10 | certname: string; 11 | environment: string; 12 | name: string; 13 | value: any; 14 | } 15 | 16 | export class PuppetFact extends autoImplement() { 17 | static fromApi(apiItem: ApiPuppetFact): PuppetFact { 18 | return new PuppetFact(apiItem); 19 | } 20 | 21 | get isJson() { 22 | return typeof this.value === 'object' && this.value !== null; 23 | } 24 | } 25 | 26 | export type PuppetQueryRequest = { 27 | Query: string; 28 | }; 29 | 30 | export interface PuppetQueryResult { 31 | Data: T; 32 | Error: string; 33 | Success: boolean; 34 | ExecutedOn: Date; 35 | ExecutionTimeInMilli: number; 36 | } 37 | 38 | export type PuppetQueryHistoryEntry = { 39 | Query: PuppetQueryRequest; 40 | Result: PuppetQueryResult; 41 | }; 42 | 43 | export interface ApiPuppetQueryPredefined { 44 | Description: string; 45 | Query: string; 46 | } 47 | 48 | export class PuppetQueryPredefined extends autoImplement() { 49 | static fromApi(apiItem: ApiPuppetQueryPredefined): PuppetQueryPredefined { 50 | return new PuppetQueryPredefined(apiItem); 51 | } 52 | } 53 | 54 | export interface ApiPredefinedViewResult { 55 | View: ApiPredefinedView; 56 | Data: unknown[]; 57 | } 58 | 59 | export class PredefinedViewResult extends autoImplement() { 60 | static fromApi(apiItem: ApiPredefinedViewResult): PredefinedViewResult { 61 | return new PredefinedViewResult(apiItem); 62 | } 63 | } 64 | 65 | export interface ApiPredefinedViewFact { 66 | Name: string; 67 | Fact: string; 68 | Renderer: string; 69 | } 70 | 71 | export interface ApiPredefinedView { 72 | Name: string; 73 | Facts: ApiPredefinedViewFact[]; 74 | } 75 | 76 | export class PredefinedView extends autoImplement() { 77 | static fromApi(apiItem: ApiPredefinedView): PredefinedView { 78 | return new PredefinedView(apiItem); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ui/src/pages/node/NodeOverviewPage.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sebastianrakel/openvoxview 2 | 3 | go 1.23.0 4 | 5 | require github.com/gin-gonic/gin v1.11.0 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.9.0 // indirect 9 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 10 | github.com/goccy/go-yaml v1.18.0 // indirect 11 | github.com/quic-go/qpack v0.5.1 // indirect 12 | github.com/quic-go/quic-go v0.54.0 // indirect 13 | github.com/sagikazarmark/locafero v0.11.0 // indirect 14 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 15 | github.com/spf13/afero v1.15.0 // indirect 16 | github.com/spf13/cast v1.10.0 // indirect 17 | github.com/spf13/pflag v1.0.10 // indirect 18 | github.com/subosito/gotenv v1.6.0 // indirect 19 | go.uber.org/mock v0.5.0 // indirect 20 | go.yaml.in/yaml/v3 v3.0.4 // indirect 21 | golang.org/x/mod v0.26.0 // indirect 22 | golang.org/x/sync v0.16.0 // indirect 23 | golang.org/x/tools v0.35.0 // indirect 24 | ) 25 | 26 | require ( 27 | github.com/bytedance/sonic v1.14.0 // indirect 28 | github.com/bytedance/sonic/loader v0.3.0 // indirect 29 | github.com/cloudwego/base64x v0.1.6 // indirect 30 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 31 | github.com/gin-contrib/sse v1.1.0 // indirect 32 | github.com/go-playground/locales v0.14.1 // indirect 33 | github.com/go-playground/universal-translator v0.18.1 // indirect 34 | github.com/go-playground/validator/v10 v10.27.0 // indirect 35 | github.com/goccy/go-json v0.10.2 // indirect 36 | github.com/json-iterator/go v1.1.12 // indirect 37 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 38 | github.com/leodido/go-urn v1.4.0 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 41 | github.com/modern-go/reflect2 v1.0.2 // indirect 42 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 43 | github.com/spf13/viper v1.21.0 44 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 45 | github.com/ugorji/go/codec v1.3.0 // indirect 46 | golang.org/x/arch v0.20.0 // indirect 47 | golang.org/x/crypto v0.40.0 // indirect 48 | golang.org/x/net v0.42.0 // indirect 49 | golang.org/x/sys v0.35.0 // indirect 50 | golang.org/x/text v0.28.0 // indirect 51 | google.golang.org/protobuf v1.36.9 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /ui/src/i18n/langs/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL_FAILURE": "failure|failures", 3 | "LABEL_SUCCESS": "success|successes", 4 | "LABEL_SKIP": "skip|skips", 5 | "LABEL_NOOP": "noop|noops", 6 | "LABEL_EXECUTE_QUERY": "execute query", 7 | "LABEL_DATA": "data", 8 | "LABEL_JSON": "JSON", 9 | "LABEL_META": "meta", 10 | "LABEL_EXECUTION_TIME": "execution time", 11 | "LABEL_EXECUTED_ON": "executed on", 12 | "MENU_FACTS": "Facts", 13 | "MENU_NODES": "Nodes", 14 | "MENU_REPORTS": "Reports", 15 | "MENU_QUERY": "Query", 16 | "LABEL_CERTNAME": "certname", 17 | "LABEL_VALUE": "value|values", 18 | "LABEL_SEARCH": "search", 19 | "LABEL_OVERVIEW": "overview", 20 | "LABEL_FACT": "fact|facts", 21 | "LABEL_DETAIL": "detail|details", 22 | "LABEL_REPORT": "report|reports", 23 | "BTN_ADD_NEW_QUERY": "add new query", 24 | "LABEL_QUERY": "query|queries", 25 | "LABEL_SUMMARY": "summary|summaries", 26 | "LABEL_LOG": "log|logs", 27 | "LABEL_EVENT": "event|events", 28 | "LABEL_FAILED": "failed", 29 | "LABEL_CHANGED": "changed", 30 | "LABEL_UNCHANGED": "unchanged", 31 | "LABEL_END_TIME": "end time", 32 | "LABEL_STATUS": "status", 33 | "LABEL_CONFIGURATION_VERSION": "configuration version", 34 | "LABEL_AGENT_VERSION": "agent version", 35 | "LABEL_FILTER": "filter|filters", 36 | "LABEL_END_TIME_START": "end time start", 37 | "LABEL_RESOURCE": "resource|resources", 38 | "LABEL_CHANGED_FROM": "changed from", 39 | "LABEL_CHANGED_TO": "changed to", 40 | "LABEL_TIMESTAMP": "timestamp", 41 | "LABEL_LEVEL": "level|levels", 42 | "LABEL_MESSAGE": "message|messages", 43 | "LABEL_SHORT": "short", 44 | "LABEL_START_TIME": "start time", 45 | "LABEL_DURATION": "duration", 46 | "LABEL_CATALOG": "catalog|catalogs", 47 | "MENU_DASHBOARD": "Dashboard", 48 | "LABEL_MENU": "Menu", 49 | "LABEL_NODE": "node|nodes", 50 | "LABEL_METRIC": "metric|metrics", 51 | "LABEL_LOCATION_SHORT": "location (short)", 52 | "LABEL_DESCRIPTION": "description", 53 | "BTN_REFRESH": "refresh", 54 | "MENU_VIEWS": "Views", 55 | "LABEL_ACTION": "action|actions", 56 | "BTN_CLOSE": "close", 57 | "KEY_CTRL_RETURN": "Ctrl + Return", 58 | "NOTIFICATION_COPY_TO_CLIPBOARD_SUCCESSFUL": "Successfully copied to clipboard.", 59 | "NOTIFICATION_COPY_TO_CLIPBOARD_FAILED": "Failed to copy to clipboard." 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/i18n/langs/de-DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL_FAILURE": "Fehler|Fehler", 3 | "LABEL_SUCCESS": "Erfolg|Erfolge", 4 | "LABEL_SKIP": "skip|skips", 5 | "LABEL_NOOP": "noop|noops", 6 | "LABEL_EXECUTE_QUERY": "Abfrage ausführen", 7 | "LABEL_DATA": "data", 8 | "LABEL_JSON": "JSON", 9 | "LABEL_META": "meta", 10 | "LABEL_EXECUTION_TIME": "Auführungszeit", 11 | "LABEL_EXECUTED_ON": "Ausführen auf", 12 | "MENU_FACTS": "Facts", 13 | "MENU_NODES": "Nodes", 14 | "MENU_REPORTS": "Reports", 15 | "MENU_QUERY": "Abfragen", 16 | "LABEL_CERTNAME": "Zertifikatsname", 17 | "LABEL_VALUE": "Wert|Werte", 18 | "LABEL_SEARCH": "Suche", 19 | "LABEL_OVERVIEW": "Übersicht", 20 | "LABEL_FACT": "fact|facts", 21 | "LABEL_DETAIL": "Detail|Details", 22 | "LABEL_REPORT": "Bericht|Berichte", 23 | "BTN_ADD_NEW_QUERY": "neue Abfrage hinzufügen", 24 | "LABEL_QUERY": "Abfrage|Abfragen", 25 | "LABEL_SUMMARY": "Zusammenfassung|Zusammenfassungen", 26 | "LABEL_LOG": "Protokoll|Protokolle", 27 | "LABEL_EVENT": "Ereigniss|Ereignisse", 28 | "LABEL_FAILED": "Fehlgeschlagen", 29 | "LABEL_CHANGED": "Geändert", 30 | "LABEL_UNCHANGED": "Unverändert", 31 | "LABEL_END_TIME": "Endzeit", 32 | "LABEL_STATUS": "Status", 33 | "LABEL_CONFIGURATION_VERSION": "Konfigurationsversion", 34 | "LABEL_AGENT_VERSION": "Agent-Version", 35 | "LABEL_FILTER": "Filter|Filter", 36 | "LABEL_END_TIME_START": "Endzeit-Start", 37 | "LABEL_RESOURCE": "Ressource|Ressourcens", 38 | "LABEL_CHANGED_FROM": "geändert von", 39 | "LABEL_CHANGED_TO": "Geändert zu", 40 | "LABEL_TIMESTAMP": "Zeitstempel", 41 | "LABEL_LEVEL": "level|levels", 42 | "LABEL_MESSAGE": "Nachricht|Nachrichten", 43 | "LABEL_SHORT": "Kurz", 44 | "LABEL_START_TIME": "Startzeit", 45 | "LABEL_DURATION": "Dauer", 46 | "LABEL_CATALOG": "catalog|catalogs", 47 | "MENU_DASHBOARD": "Dashboard", 48 | "LABEL_MENU": "Menü", 49 | "LABEL_NODE": "node|nodes", 50 | "LABEL_METRIC": "Metrik|Metriken", 51 | "LABEL_LOCATION_SHORT": "Kurzbeschreibung", 52 | "LABEL_DESCRIPTION": "Beschreibung", 53 | "BTN_REFRESH": "Aktualisieren", 54 | "MENU_VIEWS": "Ansichten", 55 | "LABEL_ACTION": "Aktion|Aktionen", 56 | "BTN_CLOSE": "Schließen", 57 | "KEY_CTRL_RETURN": "Strg + Eingabe", 58 | "NOTIFICATION_COPY_TO_CLIPBOARD_SUCCESSFUL": "Erfolgreich in die Zwischenablage kopiert.", 59 | "NOTIFICATION_COPY_TO_CLIPBOARD_FAILED": "Fehler beim Kopieren in die Zwischenablage." 60 | } 61 | -------------------------------------------------------------------------------- /ui/src/helper/functions.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment/moment'; 2 | 3 | export function autoImplement(defaults: Partial = {}) { 4 | return class { 5 | constructor(data: Partial = {}) { 6 | Object.assign(this, defaults); 7 | Object.assign(this, data); 8 | } 9 | } as new (data?: T) => T; 10 | } 11 | 12 | export function formatTimestamp(input: string|Date, withMs?: boolean) : string { 13 | return moment(input).format(`DD. MMM. YYYY - HH:mm:ss${withMs ? '.SSS' : ''}`) 14 | } 15 | 16 | export function formatDuration(milliseconds: number): string { 17 | if (milliseconds < 0) return "0 ms"; 18 | 19 | const units = [ 20 | { name: "d", value: 24 * 60 * 60 * 1000 }, 21 | { name: "h", value: 60 * 60 * 1000 }, 22 | { name: "m", value: 60 * 1000 }, 23 | { name: "s", value: 1000 }, 24 | { name: "ms", value: 1 } 25 | ]; 26 | 27 | const parts: string[] = []; 28 | let remaining = milliseconds; 29 | 30 | for (const unit of units) { 31 | if (remaining >= unit.value) { 32 | const count = Math.floor(remaining / unit.value); 33 | parts.push(`${count} ${unit.name}`); 34 | remaining %= unit.value; 35 | } 36 | } 37 | 38 | return parts.length > 0 ? parts.join(" ") : "0 ms"; 39 | } 40 | 41 | export function msToTime(duration: number) { 42 | const milliseconds = (duration%1000)/100 43 | , seconds = parseInt(((duration/1000)%60).toString()) 44 | , minutes = parseInt(((duration/(1000*60))%60).toString()) 45 | , hours = parseInt(((duration/(1000*60*60))%24).toString()); 46 | 47 | const hoursStr = (hours < 10) ? '0' + hours : hours.toString(); 48 | const minutesStr = (minutes < 10) ? '0' + minutes : minutes.toString(); 49 | const secondsStr = (seconds < 10) ? '0' + seconds : seconds.toString(); 50 | 51 | return `${hoursStr}:${minutesStr}:${secondsStr}.${milliseconds.toString().replace('.','')}`; 52 | } 53 | 54 | /* eslint-disable @typescript-eslint/no-explicit-any */ 55 | export function getOsNameFromOsFact(os_fact: any) : string { 56 | switch (os_fact['family']) { 57 | case 'windows': 58 | return os_fact['windows']['product_name'] 59 | case 'Darwin': 60 | return os_fact['macosx']['product'] 61 | default: 62 | return os_fact['distro']['description'] 63 | } 64 | } 65 | 66 | export async function copyToClipboard(payload: any): Promise { 67 | await navigator.clipboard.writeText(payload); 68 | } 69 | -------------------------------------------------------------------------------- /ui/src/client/backend.ts: -------------------------------------------------------------------------------- 1 | import { api } from 'boot/axios'; 2 | import type { AxiosPromise } from 'axios'; 3 | import type { ApiVersion, BaseResponse } from 'src/client/models'; 4 | import type PqlQuery from 'src/puppet/query-builder'; 5 | import type { 6 | ApiPredefinedView, 7 | ApiPredefinedViewResult, 8 | ApiPuppetQueryPredefined, 9 | PuppetQueryHistoryEntry, 10 | PuppetQueryRequest, 11 | PuppetQueryResult, 12 | } from 'src/puppet/models'; 13 | import { type ApiPuppetNodeWithEventCount } from 'src/puppet/models/puppet-node'; 14 | 15 | class Backend { 16 | getQueryResult(query: PqlQuery) : AxiosPromise>> { 17 | const payload = { 18 | Query: query.build(), 19 | } as PuppetQueryRequest; 20 | 21 | return api.post('/api/v1/pdb/query', payload); 22 | } 23 | 24 | getQueryHistory() : AxiosPromise>{ 25 | return api.get('/api/v1/pdb/query/history'); 26 | } 27 | 28 | getQueryPredefined() : AxiosPromise>{ 29 | return api.get('/api/v1/pdb/query/predefined'); 30 | } 31 | 32 | getRawQueryResult(query: string, save?: boolean) : AxiosPromise>> { 33 | const payload = { 34 | query: query, 35 | saveInHistory: save, 36 | } 37 | 38 | return api.post('/api/v1/pdb/query', payload); 39 | } 40 | 41 | getFactNames() : AxiosPromise> { 42 | return api.get('/api/v1/pdb/fact-names'); 43 | } 44 | 45 | getViewNodeOverview(environment: string, status?: string[]) : AxiosPromise> { 46 | let queryParams = '' 47 | if (status) { 48 | status.forEach(s => { 49 | queryParams += `&status=${s}` 50 | }) 51 | } 52 | if (queryParams != '') queryParams = `${queryParams}` 53 | 54 | return api.get(`/api/v1/view/node_overview?environment=${environment}${queryParams}`) 55 | } 56 | 57 | getPredefinedViews() : AxiosPromise> { 58 | return api.get('/api/v1/view/predefined') 59 | } 60 | 61 | getPredefinedViewsResult(viewName: string) : AxiosPromise> { 62 | return api.get(`/api/v1/view/predefined/${viewName}`) 63 | } 64 | 65 | getVersion() : AxiosPromise>{ 66 | return api.get('/api/v1/version') 67 | } 68 | } 69 | 70 | export default new Backend(); 71 | -------------------------------------------------------------------------------- /ui/src/pages/fact/FactOverviewPage.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 85 | 86 | 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "log" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/sebastianrakel/openvoxview/config" 12 | "github.com/sebastianrakel/openvoxview/handler" 13 | ) 14 | 15 | var ( 16 | VERSION = "0.1.0" 17 | COMMIT = "dirty" 18 | ) 19 | 20 | func main() { 21 | if config.PrintVersion(VERSION) { 22 | return 23 | } 24 | log.Printf("OpenVox View - %s (%s)", VERSION, COMMIT) 25 | cfg, err := config.GetConfig() 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | log.Printf("LISTEN: %s", cfg.Listen) 31 | log.Printf("PORT: %d", cfg.Port) 32 | log.Printf("PUPPETDB_ADDRESS: %s", cfg.GetPuppetDbAddress()) 33 | 34 | r := gin.Default() 35 | 36 | r.NoRoute(func(c *gin.Context) { 37 | if strings.HasPrefix(c.Request.URL.Path, "/api/") { 38 | c.Next() 39 | return 40 | } 41 | 42 | c.Redirect(http.StatusTemporaryRedirect, "/ui/?#/") 43 | }) 44 | 45 | uiFSSub, _ := fs.Sub(uiFS, "ui/dist/spa") 46 | r.StaticFS("ui", http.FS(uiFSSub)) 47 | 48 | r.Use(AllowCORS) 49 | 50 | pdbHandler := handler.NewPdbHandler(cfg) 51 | viewHandler := handler.NewViewHandler(cfg) 52 | 53 | api := r.Group("/api/v1/") 54 | { 55 | api.GET("version", func(c *gin.Context) { 56 | type versionResponse struct { 57 | Version string 58 | } 59 | 60 | response := versionResponse{ 61 | Version: VERSION, 62 | } 63 | 64 | c.JSON(http.StatusOK, handler.NewSuccessResponse(response)) 65 | }) 66 | view := api.Group("view") 67 | { 68 | view.GET("node_overview", viewHandler.NodesOverview) 69 | view.GET("metrics", viewHandler.Metrics) 70 | view.GET("predefined", viewHandler.PredefinedViews) 71 | view.GET("predefined/:viewName", viewHandler.PredefinedViewsResult) 72 | } 73 | 74 | pdb := api.Group("pdb") 75 | { 76 | pdb.POST("query", pdbHandler.PdbExecuteQuery) 77 | pdb.GET("query/history", pdbHandler.PdbQueryHistory) 78 | pdb.GET("query/predefined", pdbHandler.PdbQueryPredefined) 79 | pdb.GET("fact-names", pdbHandler.PdbGetFactNames) 80 | pdb.POST("event-counts", pdbHandler.PdbGetEventCounts) 81 | } 82 | } 83 | 84 | r.Run(fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)) 85 | } 86 | 87 | func AllowCORS(c *gin.Context) { 88 | c.Header("Access-Control-Allow-Origin", "*") 89 | c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") 90 | c.Header("Access-Control-Allow-Headers", "Authorization, *") 91 | 92 | if c.Request.Method == http.MethodOptions { 93 | c.Status(http.StatusNoContent) 94 | return 95 | } 96 | 97 | c.Next() 98 | } 99 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/sebastianrakel/openvoxview/model" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var configPath = flag.String("config", "config.yml", "path to the config file ") 13 | var printVersion = flag.Bool("version", false, "prints version") 14 | 15 | func init() { 16 | flag.Parse() 17 | } 18 | 19 | type ConfigPqlQuery struct { 20 | Description string `mapstructure:"description"` 21 | Query string `mapstructure:"query"` 22 | } 23 | 24 | type Config struct { 25 | Listen string `mapstructure:"listen"` 26 | Port uint64 `mapstructure:"port"` 27 | PuppetDB struct { 28 | Host string `mapstructure:"host"` 29 | Port uint64 `mapstructure:"port"` 30 | TLS bool `mapstructure:"tls"` 31 | TLSIgnore bool `mapstructure:"tls_ignore"` 32 | TLS_CA string `mapstructure:"tls_ca"` 33 | TLS_KEY string `mapstructure:"tls_key"` 34 | TLS_CERT string `mapstructure:"tls_cert"` 35 | } `mapstructure:"puppetdb"` 36 | PqlQueries []ConfigPqlQuery `mapstructure:"queries"` 37 | Views []model.View `mapstructure:"views"` 38 | } 39 | 40 | func PrintVersion(version string) bool { 41 | if *printVersion { 42 | fmt.Println(version) 43 | return true 44 | } 45 | return false 46 | } 47 | 48 | func GetConfig() (*Config, error) { 49 | viper.SetConfigName("config") 50 | viper.SetConfigType("yaml") 51 | viper.AddConfigPath(".") 52 | 53 | if configPath != nil { 54 | log.Printf("Using config: %s", *configPath) 55 | viper.SetConfigFile(*configPath) 56 | } 57 | 58 | viper.SetDefault("port", 5000) 59 | viper.SetDefault("puppetdb.host", "localhost") 60 | viper.SetDefault("puppetdb.port", 8080) 61 | viper.SetDefault("puppetdb.tls_ignore", false) 62 | 63 | viper.AutomaticEnv() 64 | 65 | viper.BindEnv("port", "PORT") 66 | viper.BindEnv("listen", "LISTEN") 67 | viper.BindEnv("puppetdb.port", "PUPPETDB_PORT") 68 | viper.BindEnv("puppetdb.host", "PUPPETDB_HOST") 69 | viper.BindEnv("puppetdb.tls", "PUPPETDB_TLS") 70 | viper.BindEnv("puppetdb.tls_ignore", "PUPPETDB_TLS_IGNORE") 71 | viper.BindEnv("puppetdb.tls_ca", "PUPPETDB_TLS_CA") 72 | viper.BindEnv("puppetdb.tls_key", "PUPPETDB_TLS_KEY") 73 | viper.BindEnv("puppetdb.tls_cert", "PUPPETDB_TLS_CERT") 74 | 75 | viper.ReadInConfig() 76 | 77 | var cfg Config 78 | 79 | err := viper.Unmarshal(&cfg) 80 | 81 | return &cfg, err 82 | } 83 | 84 | func (c *Config) GetPuppetDbAddress() string { 85 | scheme := "http" 86 | if c.PuppetDB.TLS { 87 | scheme = "https" 88 | } 89 | 90 | return fmt.Sprintf("%s://%s:%d", scheme, c.PuppetDB.Host, c.PuppetDB.Port) 91 | } 92 | -------------------------------------------------------------------------------- /ui/src/components/NodeTable.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import pluginVue from 'eslint-plugin-vue' 4 | import pluginQuasar from '@quasar/app-vite/eslint' 5 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' 6 | 7 | // the following is optional, if you want prettier too: 8 | import prettierSkipFormatting from '@vue/eslint-config-prettier/skip-formatting' 9 | 10 | export default defineConfigWithVueTs( 11 | { 12 | /** 13 | * Ignore the following files. 14 | * Please note that pluginQuasar.configs.recommended() already ignores 15 | * the "node_modules" folder for you (and all other Quasar project 16 | * relevant folders and files). 17 | * 18 | * ESLint requires "ignores" key to be the only one in this object 19 | */ 20 | // ignores: [] 21 | }, 22 | 23 | pluginQuasar.configs.recommended(), 24 | js.configs.recommended, 25 | 26 | /** 27 | * https://eslint.vuejs.org 28 | * 29 | * pluginVue.configs.base 30 | * -> Settings and rules to enable correct ESLint parsing. 31 | * pluginVue.configs[ 'flat/essential'] 32 | * -> base, plus rules to prevent errors or unintended behavior. 33 | * pluginVue.configs["flat/strongly-recommended"] 34 | * -> Above, plus rules to considerably improve code readability and/or dev experience. 35 | * pluginVue.configs["flat/recommended"] 36 | * -> Above, plus rules to enforce subjective community defaults to ensure consistency. 37 | */ 38 | pluginVue.configs[ 'flat/essential' ], 39 | 40 | { 41 | files: ['**/*.ts', '**/*.vue'], 42 | rules: { 43 | '@typescript-eslint/consistent-type-imports': [ 44 | 'error', 45 | { prefer: 'type-imports' } 46 | ], 47 | } 48 | }, 49 | vueTsConfigs.recommendedTypeChecked, 50 | 51 | { 52 | languageOptions: { 53 | ecmaVersion: 'latest', 54 | sourceType: 'module', 55 | 56 | globals: { 57 | ...globals.browser, 58 | ...globals.node, // SSR, Electron, config files 59 | process: 'readonly', // process.env.* 60 | ga: 'readonly', // Google Analytics 61 | cordova: 'readonly', 62 | Capacitor: 'readonly', 63 | chrome: 'readonly', // BEX related 64 | browser: 'readonly' // BEX related 65 | } 66 | }, 67 | 68 | // add your custom rules here 69 | rules: { 70 | 'prefer-promise-reject-errors': 'off', 71 | 72 | // allow debugger during development only 73 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 74 | } 75 | }, 76 | 77 | { 78 | files: [ 'src-pwa/custom-service-worker.ts' ], 79 | languageOptions: { 80 | globals: { 81 | ...globals.serviceworker 82 | } 83 | } 84 | }, 85 | 86 | prettierSkipFormatting // optional, if you want prettier 87 | ) 88 | -------------------------------------------------------------------------------- /ui/src/pages/views/PredefinedViewResultPage.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ui/src/pages/fact/FactDetailPage.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /handler/pdb.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/sebastianrakel/openvoxview/config" 11 | "github.com/sebastianrakel/openvoxview/puppetdb" 12 | ) 13 | 14 | type QueryRequest struct { 15 | Query string 16 | SaveInHistory bool 17 | } 18 | 19 | type QueryResult struct { 20 | Data []json.RawMessage 21 | Error error 22 | Success bool 23 | ExecutedOn time.Time 24 | ExecutionTimeInMilli int64 25 | Count int 26 | } 27 | 28 | type PqlHistoryEntry struct { 29 | Query QueryRequest 30 | Result QueryResult 31 | } 32 | 33 | type PdbHandler struct { 34 | QueryHistory []PqlHistoryEntry 35 | config *config.Config 36 | } 37 | 38 | func NewPdbHandler(config *config.Config) *PdbHandler { 39 | return &PdbHandler{ 40 | QueryHistory: []PqlHistoryEntry{}, 41 | config: config, 42 | } 43 | } 44 | 45 | func (h *PdbHandler) PdbExecuteQuery(c *gin.Context) { 46 | var queryRequest QueryRequest 47 | c.BindJSON(&queryRequest) 48 | 49 | dbClient := puppetdb.NewClient() 50 | log.Printf("Executing Query: %s", queryRequest.Query) 51 | 52 | historyEntry := PqlHistoryEntry{ 53 | Query: queryRequest, 54 | } 55 | 56 | start := time.Now() 57 | res, err := dbClient.Query(queryRequest.Query) 58 | end := time.Now() 59 | 60 | duration := end.Sub(start).Milliseconds() 61 | 62 | queryResult := QueryResult{ 63 | Data: res, 64 | Error: err, 65 | Success: err == nil, 66 | ExecutedOn: time.Now(), 67 | ExecutionTimeInMilli: duration, 68 | Count: len(res), 69 | } 70 | 71 | historyEntry.Result = queryResult 72 | 73 | if queryRequest.SaveInHistory { 74 | h.QueryHistory = append(h.QueryHistory, historyEntry) 75 | } 76 | if err != nil { 77 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 78 | return 79 | } 80 | 81 | c.JSON(http.StatusOK, NewSuccessResponse(queryResult)) 82 | } 83 | 84 | func (h *PdbHandler) PdbQueryHistory(c *gin.Context) { 85 | c.JSON(http.StatusOK, NewSuccessResponse(h.QueryHistory)) 86 | } 87 | 88 | func (h *PdbHandler) PdbQueryPredefined(c *gin.Context) { 89 | result := h.config.PqlQueries 90 | 91 | if result == nil { 92 | result = []config.ConfigPqlQuery{} 93 | } 94 | 95 | c.JSON(http.StatusOK, NewSuccessResponse(result)) 96 | } 97 | 98 | func (h *PdbHandler) PdbGetFactNames(c *gin.Context) { 99 | dbClient := puppetdb.NewClient() 100 | 101 | res, err := dbClient.GetFactNames() 102 | if err != nil { 103 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 104 | return 105 | } 106 | 107 | c.JSON(http.StatusOK, NewSuccessResponse(res)) 108 | } 109 | 110 | func (h *PdbHandler) PdbGetEventCounts(c *gin.Context) { 111 | var query puppetdb.PdbQuery 112 | err := c.BindJSON(&query) 113 | if err != nil { 114 | c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) 115 | return 116 | } 117 | 118 | dbClient := puppetdb.NewClient() 119 | 120 | res, err := dbClient.GetEventCounts(&query) 121 | if err != nil { 122 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 123 | return 124 | } 125 | 126 | c.JSON(http.StatusOK, NewSuccessResponse(res)) 127 | } 128 | -------------------------------------------------------------------------------- /ui/src/pages/report/ReportDetailPage.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /ui/src/puppet/models/puppet-report.ts: -------------------------------------------------------------------------------- 1 | import { autoImplement, msToTime } from 'src/helper/functions'; 2 | import { 3 | type ApiPuppetEventCount, 4 | PuppetEventCount, 5 | } from 'src/puppet/models/puppet-event-count'; 6 | import moment from 'moment'; 7 | 8 | export interface ApiPuppetReportMetric { 9 | category: string; 10 | name: string; 11 | value: number; 12 | } 13 | 14 | export class PuppetReportMetric extends autoImplement() { 15 | static fromApi(apiItem: ApiPuppetReportMetric): PuppetReportMetric { 16 | return PuppetReportMetric.fromApi(apiItem); 17 | } 18 | } 19 | 20 | export type PuppetReportMetrics = { 21 | data: ApiPuppetReportMetric[]; 22 | href: string; 23 | }; 24 | 25 | export type PuppetReportLogs = { 26 | data: ApiPuppetReportLog[]; 27 | href: string; 28 | }; 29 | 30 | export interface ApiPuppetReportLog { 31 | file: string; 32 | level: string; 33 | line: number; 34 | message: string; 35 | source: string; 36 | tags: string[]; 37 | time: Date; 38 | } 39 | 40 | export class PuppetReportLog extends autoImplement() { 41 | static fromApi(apiItem: ApiPuppetReportLog): PuppetReportLog { 42 | return new PuppetReportLog(apiItem); 43 | } 44 | 45 | get color() { 46 | switch (this.level) { 47 | case 'info': 48 | return 'primary'; 49 | case 'notice': 50 | return 'secondary'; 51 | case 'err': 52 | return 'negative'; 53 | case 'warning': 54 | return 'warning'; 55 | } 56 | } 57 | } 58 | 59 | export interface ApiPuppetReport { 60 | cached_catalog_status: string; 61 | catalog_uuid: string; 62 | certname: string; 63 | code_id: null; 64 | configuration_version: string; 65 | corrective_change: null; 66 | start_time: Date; 67 | end_time: Date; 68 | environment: string; 69 | hash: string; 70 | job_id: null; 71 | logs: PuppetReportLogs; 72 | metrics: PuppetReportMetrics; 73 | noop: boolean; 74 | noop_pending: boolean; 75 | producer: string; 76 | producer_timestamp: Date; 77 | puppet_version: string; 78 | receive_time: Date; 79 | report_format: number; 80 | status: string; 81 | transaction_uuid: string; 82 | type: string; 83 | } 84 | 85 | export class PuppetReport extends autoImplement() { 86 | static fromApi(apiItem: ApiPuppetReport): PuppetReport { 87 | return new PuppetReport(apiItem); 88 | } 89 | 90 | public getMetricsValue(category: string, name: string) { 91 | return this.metrics.data.filter( 92 | (s) => s.category == category && s.name == name 93 | )[0]!.value; 94 | } 95 | 96 | public getEventCounts(): PuppetEventCount { 97 | return new PuppetEventCount({ 98 | successes: this.getMetricsValue('events', 'success'), 99 | failures: this.getMetricsValue('events', 'failure'), 100 | skips: this.getMetricsValue('resources', 'skipped'), 101 | noops: 0, 102 | } as ApiPuppetEventCount); 103 | } 104 | 105 | get endTimeFormatted() { 106 | return moment(this.end_time).format('DD. MMM. YYYY - HH:mm:ss'); 107 | } 108 | 109 | get durationInMs() { 110 | return ( 111 | new Date(this.end_time).valueOf() - new Date(this.start_time).valueOf() 112 | ); 113 | } 114 | 115 | get durationFormatted() { 116 | return msToTime(this.durationInMs); 117 | } 118 | 119 | get logsMapped() { 120 | return this.logs.data.map((s) => PuppetReportLog.fromApi(s)); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configuration can be done by config yaml file environment variable (without predefined queries/views) 4 | 5 | Default it will look for a config.yaml in the current directory, 6 | but you can pass the -config parameter to define the location of the config file 7 | 8 | ## Options 9 | | Option | Environment Variable | Default | Type | Description | 10 | |---------------------|----------------------|-----------|--------|---------------------------------------| 11 | | listen | LISTEN | localhost | string | Listen host/ip | 12 | | port | PORT | 5000 | int | Listen to port | 13 | | puppetdb.host | PUPPETDB_HOST | localhost | string | Address of puppetdb | 14 | | puppetdb.port | PUPPETDB_PORT | 8080 | int | Port of puppetdb | 15 | | puppetdb.tls | PUPPETDB_TLS | false | bool | Communicate over tls with puppetdb | 16 | | puppetdb.tls_ignore | PUPPETDB_TLS_IGNORE | false | bool | Ignore validation of tls certificate | 17 | | puppetdb.tls_ca | PUPPETDB_TLS_CA | | string | Path to ca cert file for puppetdb | 18 | | puppetdb.tls_key | PUPPETDB_TLS_KEY | | string | Path to client key file for puppetdb | 19 | | puppetdb.tls_crt | PUPPETDB_TLS_CERT | | string | Path to client cert file for puppetdb | 20 | | queries | | | array | predefined queries (see query table) | 21 | | views | | | array | predefined views (see view table) | 22 | 23 | ### predefined Queries 24 | | Option | Type | Description | 25 | |-------------|--------|---------------------------| 26 | | description | string | description of your query | 27 | | query | string | PQL query string | 28 | | | | | 29 | 30 | ### predefined Views 31 | | Option | Type | Description | 32 | |--------|--------|---------------------------------------------------| 33 | | name | string | description of your query | 34 | | facts | array | facts that should be shown (see view facts table) | 35 | 36 | 37 | ### predefined Views - Facts 38 | | Option | Type | Description | 39 | |----------|--------|-------------------------------------------------------------------------------------| 40 | | name | string | column name that should be shown | 41 | | fact | string | which fact should be shown (can be . seperated for lower level (like networking.ip) | 42 | | renderer | string | (optional) there are some renderer like hostname or os_name | 43 | 44 | ## YAML Example 45 | 46 | ```yaml 47 | --- 48 | listen: 127.0.0.1 49 | port: 5000 50 | 51 | puppetdb: 52 | host: localhost 53 | port: 8081 54 | tls: true 55 | tls_ignore: false 56 | tls_ca: /path/to/ca.crt 57 | tls_key: /path/to/cert.key 58 | tls_cert: /path/to/cert.crt 59 | 60 | queries: 61 | - description: Inactive Nodes 62 | query: nodes[certname] { node_state = "inactive" } 63 | 64 | views: 65 | - name: 'Inventory' 66 | facts: 67 | - name: 'Hostname' 68 | fact: 'trusted' 69 | renderer: 'hostname' 70 | - name: 'IP Address' 71 | fact: 'networking.ip' 72 | - name: 'OS' 73 | fact: 'os' 74 | renderer: 'os_name' 75 | - name: 'Kernel Version' 76 | fact: 'kernelrelease' 77 | - name: 'Puppet Version' 78 | fact: 'puppetversion' 79 | ``` 80 | -------------------------------------------------------------------------------- /ui/src/assets/quasar-logo-vertical.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /puppetdb/client.go: -------------------------------------------------------------------------------- 1 | package puppetdb 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | 14 | "github.com/sebastianrakel/openvoxview/config" 15 | "github.com/sebastianrakel/openvoxview/model" 16 | ) 17 | 18 | type client struct { 19 | } 20 | 21 | type PdbQuery struct { 22 | Query []any `json:"query"` 23 | SummarizeBy string `json:"summarize_by,omitempty"` 24 | } 25 | 26 | func NewClient() *client { 27 | return &client{} 28 | } 29 | 30 | func (c *client) call(httpMethod string, endpoint string, payload any, query url.Values, responseData any) (*http.Response, error) { 31 | cfg, err := config.GetConfig() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | uri := fmt.Sprintf("%s/%s", cfg.GetPuppetDbAddress(), endpoint) 37 | if query != nil { 38 | uri = fmt.Sprintf("%s?%s", uri, query.Encode()) 39 | } 40 | 41 | var data []byte 42 | 43 | if payload != nil { 44 | data, err = json.Marshal(&payload) 45 | if err != nil { 46 | fmt.Printf("err: %s", err) 47 | } 48 | fmt.Printf("Payload:\n%s\n", data) 49 | } 50 | 51 | fmt.Printf("HTTP: %#v: %#v\n", httpMethod, uri) 52 | 53 | var tlsConfig *tls.Config 54 | 55 | if cfg.PuppetDB.TLS { 56 | tlsConfig = &tls.Config{ 57 | InsecureSkipVerify: cfg.PuppetDB.TLSIgnore, 58 | } 59 | 60 | if cfg.PuppetDB.TLS_CA != "" { 61 | caCert, err := os.ReadFile(cfg.PuppetDB.TLS_CA) 62 | if err != nil { 63 | return nil, err 64 | } 65 | caCertPool := x509.NewCertPool() 66 | caCertPool.AppendCertsFromPEM(caCert) 67 | tlsConfig.RootCAs = caCertPool 68 | } 69 | 70 | if cfg.PuppetDB.TLS_KEY != "" { 71 | cer, err := tls.LoadX509KeyPair(cfg.PuppetDB.TLS_CERT, cfg.PuppetDB.TLS_KEY) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | tlsConfig.Certificates = []tls.Certificate{cer} 77 | } 78 | } 79 | 80 | tr := &http.Transport{ 81 | TLSClientConfig: tlsConfig, 82 | } 83 | 84 | httpClient := &http.Client{ 85 | Transport: tr, 86 | } 87 | 88 | req, err := http.NewRequest(httpMethod, uri, bytes.NewBuffer(data)) 89 | if err != nil { 90 | return nil, err 91 | } 92 | req.Header.Set("Content-Type", "application/json; charset=UTF-8") 93 | 94 | resp, err := httpClient.Do(req) 95 | if err != nil { 96 | return resp, err 97 | } 98 | 99 | defer resp.Body.Close() 100 | 101 | responseRaw, err := io.ReadAll(resp.Body) 102 | if err != nil { 103 | return resp, err 104 | } 105 | 106 | if resp.StatusCode == http.StatusOK { 107 | err = json.Unmarshal(responseRaw, responseData) 108 | if err != nil { 109 | return resp, err 110 | } 111 | } 112 | 113 | return resp, nil 114 | } 115 | 116 | func (c *client) Query(query string) ([]json.RawMessage, error) { 117 | type PuppetDbQueryRequest struct { 118 | Query string `json:"query"` 119 | } 120 | 121 | requestBody := PuppetDbQueryRequest{ 122 | Query: query, 123 | } 124 | 125 | resp := []json.RawMessage{} 126 | 127 | _, err := c.call(http.MethodPost, "pdb/query/v4", &requestBody, nil, &resp) 128 | return resp, err 129 | } 130 | 131 | func (c *client) GetFacts(query *PdbQuery) ([]model.Fact, error) { 132 | var resp []model.Fact 133 | _, err := c.call(http.MethodPost, "pdb/query/v4/facts", query, nil, &resp) 134 | return resp, err 135 | } 136 | 137 | func (c *client) GetFactNames() (json.RawMessage, error) { 138 | resp := json.RawMessage{} 139 | _, err := c.call(http.MethodGet, "pdb/query/v4/fact-names", nil, nil, &resp) 140 | return resp, err 141 | } 142 | 143 | func (c *client) GetEventCounts(query *PdbQuery) ([]model.EventCount, error) { 144 | var resp []model.EventCount 145 | _, err := c.call(http.MethodPost, "pdb/query/v4/event-counts", query, nil, &resp) 146 | return resp, err 147 | } 148 | 149 | func (c *client) GetNodes(query *PdbQuery) ([]model.Node, error) { 150 | var resp []model.Node 151 | _, err := c.call(http.MethodPost, "pdb/query/v4/nodes", query, nil, &resp) 152 | return resp, err 153 | } 154 | 155 | func (c *client) GetMetric(metricName string) (model.Metric, error) { 156 | var resp model.Metric 157 | _, err := c.call(http.MethodGet, fmt.Sprintf("metrics/v2/%s", metricName), nil, nil, &resp) 158 | return resp, err 159 | } 160 | 161 | func (c *client) GetMetricList() (model.MetricList, error) { 162 | var resp model.MetricList 163 | _, err := c.call(http.MethodGet, "metrics/v2/list", nil, nil, &resp) 164 | return resp, err 165 | } 166 | -------------------------------------------------------------------------------- /handler/view.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/sebastianrakel/openvoxview/config" 11 | "github.com/sebastianrakel/openvoxview/model" 12 | "github.com/sebastianrakel/openvoxview/puppetdb" 13 | ) 14 | 15 | type ViewHandler struct { 16 | config *config.Config 17 | } 18 | 19 | func NewViewHandler(config *config.Config) *ViewHandler { 20 | return &ViewHandler{ 21 | config: config, 22 | } 23 | } 24 | 25 | type NodesOverviewQuery struct { 26 | Environment string `form:"environment"` 27 | Status []string `form:"status"` 28 | } 29 | 30 | func (n *NodesOverviewQuery) HasEnvironment() bool { 31 | return n.Environment != "*" && n.Environment != "" 32 | } 33 | 34 | func (h *ViewHandler) NodesOverview(c *gin.Context) { 35 | var nodesOverviewQuery NodesOverviewQuery 36 | err := c.BindQuery(&nodesOverviewQuery) 37 | if err != nil { 38 | c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(err)) 39 | return 40 | } 41 | 42 | dbClient := puppetdb.NewClient() 43 | 44 | eventCountsQuery := puppetdb.PdbQuery{ 45 | Query: []any{ 46 | "=", 47 | "latest_report?", 48 | true, 49 | }, 50 | SummarizeBy: "certname", 51 | } 52 | 53 | eventCounts, err := dbClient.GetEventCounts(&eventCountsQuery) 54 | if err != nil { 55 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 56 | return 57 | } 58 | 59 | nodesQuery := &puppetdb.PdbQuery{ 60 | Query: []any{}, 61 | } 62 | 63 | if nodesOverviewQuery.HasEnvironment() { 64 | nodesQuery.Query = append(nodesQuery.Query, 65 | "and", 66 | []any{ 67 | "=", 68 | "catalog_environment", 69 | nodesOverviewQuery.Environment, 70 | }) 71 | } 72 | 73 | if len(nodesOverviewQuery.Status) > 0 { 74 | orQuery := []any{ 75 | "or", 76 | } 77 | 78 | for _, status := range nodesOverviewQuery.Status { 79 | orQuery = append(orQuery, []any{"=", "latest_report_status", status}) 80 | } 81 | 82 | nodesQuery.Query = append(nodesQuery.Query, orQuery) 83 | } 84 | 85 | if len(nodesQuery.Query) == 0 { 86 | nodesQuery = nil 87 | } 88 | 89 | nodes, err := dbClient.GetNodes(nodesQuery) 90 | if err != nil { 91 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 92 | return 93 | } 94 | 95 | for i, node := range nodes { 96 | eventIndex := slices.IndexFunc(eventCounts, func(n model.EventCount) bool { 97 | return n.Subject.Title == node.Name 98 | }) 99 | 100 | if eventIndex >= 0 { 101 | nodes[i].Events = eventCounts[eventIndex] 102 | } 103 | } 104 | 105 | c.JSON(http.StatusOK, NewSuccessResponse(nodes)) 106 | } 107 | 108 | func (h *ViewHandler) Metrics(c *gin.Context) { 109 | environment := c.Query("environment") 110 | 111 | dbClient := puppetdb.NewClient() 112 | 113 | if environment == "" || environment == "*" { 114 | dbClient.GetMetricList() 115 | } else { 116 | 117 | } 118 | } 119 | 120 | func (h *ViewHandler) PredefinedViews(c *gin.Context) { 121 | views := h.config.Views 122 | if views == nil { 123 | views = []model.View{} 124 | } 125 | 126 | c.JSON(http.StatusOK, NewSuccessResponse(views)) 127 | } 128 | 129 | func (h *ViewHandler) PredefinedViewsResult(c *gin.Context) { 130 | viewName := c.Param("viewName") 131 | 132 | if viewName == "" { 133 | c.AbortWithStatusJSON(http.StatusBadRequest, NewErrorResponse(errors.New("no view name"))) 134 | return 135 | } 136 | 137 | i := slices.IndexFunc(h.config.Views, func(n model.View) bool { 138 | return n.Name == viewName 139 | }) 140 | 141 | if i < 0 { 142 | c.AbortWithStatusJSON(http.StatusNotFound, NewErrorResponse(errors.New("view does not exists"))) 143 | return 144 | } 145 | 146 | predefinedView := h.config.Views[i] 147 | orQuery := []any{ 148 | "or", 149 | } 150 | 151 | for _, fact := range predefinedView.Facts { 152 | root_fact := fact.Fact 153 | 154 | if strings.Contains(fact.Fact, ".") { 155 | sections := strings.Split(fact.Fact, ".") 156 | root_fact = sections[0] 157 | } 158 | 159 | orQuery = append(orQuery, []any{"=", "name", root_fact}) 160 | } 161 | 162 | factsQuery := puppetdb.PdbQuery{ 163 | Query: []any{ 164 | "and", 165 | orQuery, 166 | }, 167 | } 168 | 169 | dbClient := puppetdb.NewClient() 170 | facts, err := dbClient.GetFacts(&factsQuery) 171 | if err != nil { 172 | c.AbortWithStatusJSON(http.StatusInternalServerError, NewErrorResponse(err)) 173 | return 174 | } 175 | 176 | mapped := map[string]map[string]any{} 177 | for _, fact := range facts { 178 | if _, exists := mapped[fact.Certname]; !exists { 179 | mapped[fact.Certname] = map[string]any{} 180 | } 181 | 182 | mapped[fact.Certname][fact.Name] = fact.Value 183 | } 184 | 185 | flattend := []map[string]any{} 186 | for _, value := range mapped { 187 | flattend = append(flattend, value) 188 | } 189 | 190 | result := model.ViewResult{ 191 | View: predefinedView, 192 | Data: flattend, 193 | } 194 | 195 | c.JSON(http.StatusOK, NewSuccessResponse(result)) 196 | } 197 | -------------------------------------------------------------------------------- /ui/src/pages/DashboardPage.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /ui/src/layouts/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 108 | 109 | 167 | -------------------------------------------------------------------------------- /ui/src/pages/QueryPage.vue: -------------------------------------------------------------------------------- 1 | 96 | 97 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /ui/src/components/QueryExecuter.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /ui/src/pages/report/ReportOverviewPage.vue: -------------------------------------------------------------------------------- 1 | 151 | 152 | 238 | 239 | 242 | -------------------------------------------------------------------------------- /ui/src/puppet/query-builder.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // this needs a lot of refactoring to work with queries 3 | class PqlQueryFilterField { 4 | parent: PqlQueryFilter; 5 | value: any; 6 | filterString = ''; 7 | operator: string; 8 | group: number; 9 | 10 | constructor(parent: PqlQueryFilter, operator: string, group: number) { 11 | this.parent = parent; 12 | this.operator = operator; 13 | this.group = group; 14 | } 15 | 16 | private getFieldValue(value: any): string { 17 | if (typeof value == 'string') { 18 | return `'${value}'`; 19 | } 20 | 21 | return value; 22 | } 23 | 24 | lowerThanEqual(fieldName: string, value: any): PqlQueryFilter { 25 | this.filterString = `${fieldName} <= ${this.getFieldValue(value)}`; 26 | return this.parent; 27 | } 28 | 29 | greaterThanEqual(fieldName: string, value: any): PqlQueryFilter { 30 | this.filterString = `${fieldName} >= ${this.getFieldValue(value)}`; 31 | return this.parent; 32 | } 33 | 34 | equal(fieldName: string, value: any): PqlQueryFilter { 35 | this.filterString = `${fieldName} = ${this.getFieldValue(value)}`; 36 | return this.parent; 37 | } 38 | 39 | regex(fieldName: string, value: string): PqlQueryFilter { 40 | this.filterString = `${fieldName} ~ ${this.getFieldValue(value)}`; 41 | return this.parent; 42 | } 43 | 44 | inSubQuery(fieldName: string, subqueryBuilder: PqlQuery): PqlQueryFilter { 45 | this.filterString = `${fieldName} in ${subqueryBuilder.build()}`; 46 | return this.parent; 47 | } 48 | 49 | in(fieldName: string, values: any[]): PqlQueryFilter { 50 | let filter = ''; 51 | 52 | values.forEach((value) => { 53 | if (filter != '') filter = filter + ', '; 54 | filter = filter + this.getFieldValue(value); 55 | }) 56 | this.filterString = `${fieldName} in [${filter}]`; 57 | return this.parent; 58 | } 59 | 60 | build() { 61 | return this.filterString; 62 | } 63 | 64 | done(): PqlQueryFilter { 65 | return this.parent; 66 | } 67 | } 68 | 69 | class PqlQueryFilter { 70 | private readonly parent: PqlQuery; 71 | private fields = [] as PqlQueryFilterField[]; 72 | private currentGroup: number = 0; 73 | 74 | constructor(parent: PqlQuery) { 75 | this.parent = parent; 76 | } 77 | 78 | private addField(operator: string): PqlQueryFilterField { 79 | const field = new PqlQueryFilterField(this, operator, this.currentGroup); 80 | this.fields.push(field); 81 | return field; 82 | } 83 | 84 | newGroup(): PqlQueryFilter { 85 | this.currentGroup += 1; 86 | return this; 87 | } 88 | 89 | and(): PqlQueryFilterField { 90 | return this.addField('and'); 91 | } 92 | 93 | or(): PqlQueryFilterField { 94 | return this.addField('or'); 95 | } 96 | 97 | build(): string { 98 | let query = ''; 99 | 100 | type Grouped = { 101 | [detail: string]: PqlQueryFilterField[]; 102 | } 103 | 104 | const grouped: Grouped = this.fields.reduce((result, currentValue) => { 105 | (result[currentValue['group'].toString()] = result[currentValue['group'].toString()] || []).push(currentValue); 106 | return result; 107 | }, {} as Grouped) 108 | 109 | Object.keys(grouped).forEach((groupNumber) => { 110 | let groupQuery = ''; 111 | grouped[groupNumber]!.forEach((field: PqlQueryFilterField) => { 112 | if (groupQuery.length > 0) { 113 | groupQuery = `${groupQuery} ${field.operator} `; 114 | } 115 | 116 | groupQuery = groupQuery + `${field.build()}`; 117 | }) 118 | 119 | if (query.length > 0) { 120 | query = query + ` ${grouped[groupNumber]![0]!.operator} ` 121 | } 122 | query = query + `(${groupQuery})`; 123 | }) 124 | 125 | return query; 126 | } 127 | 128 | done(): PqlQuery { 129 | return this.parent; 130 | } 131 | } 132 | 133 | export enum PqlSortOrder { 134 | Ascending = 'asc', 135 | Descending = 'desc', 136 | } 137 | 138 | class PqlQueryGroup { 139 | private readonly parent: PqlQuery; 140 | private fields = [] as string[]; 141 | 142 | constructor(parent: PqlQuery) { 143 | this.parent = parent; 144 | } 145 | 146 | add(field: string): PqlQueryGroup { 147 | this.fields.push(field); 148 | return this; 149 | } 150 | 151 | done(): PqlQuery { 152 | return this.parent; 153 | } 154 | 155 | build(): string { 156 | if (this.fields.length == 0) { 157 | return ''; 158 | } 159 | 160 | const sortFields = this.fields.join(','); 161 | return ` group by ${sortFields}`; 162 | } 163 | } 164 | 165 | interface PqlSortField { 166 | field: string; 167 | order: PqlSortOrder; 168 | } 169 | 170 | class PqlQuerySort { 171 | private readonly parent: PqlQuery; 172 | private fields = [] as PqlSortField[]; 173 | 174 | constructor(parent: PqlQuery) { 175 | this.parent = parent; 176 | } 177 | 178 | add(field: string, order: PqlSortOrder): PqlQuerySort { 179 | this.fields.push({ 180 | field: field, 181 | order: order, 182 | } as PqlSortField); 183 | 184 | return this; 185 | } 186 | 187 | done(): PqlQuery { 188 | return this.parent; 189 | } 190 | 191 | build(): string { 192 | if (this.fields.length == 0) { 193 | return ''; 194 | } 195 | 196 | const sortFields = this.fields 197 | .map((field) => `${field.field} ${field.order}`) 198 | .join(','); 199 | 200 | return ` order by ${sortFields}`; 201 | } 202 | } 203 | 204 | export enum PqlEntity { 205 | Catalogs = 'catalogs', 206 | Edges = 'edges', 207 | Events = 'events', 208 | Inventory = 'inventory', 209 | Environments = 'environments', 210 | FactContents = 'fact_contents', 211 | FactPaths = 'fact_paths', 212 | FactSets = 'factsets', 213 | Facts = 'facts', 214 | Nodes = 'nodes', 215 | PackageInventory = 'package_inventory', 216 | Packages = 'packages', 217 | Reports = 'reports', 218 | Resources = 'resources', 219 | } 220 | 221 | export default class PqlQuery { 222 | private readonly entity: PqlEntity; 223 | private projectionFields = [] as string[]; 224 | private filterBuilder = new PqlQueryFilter(this); 225 | private sortBuilder = new PqlQuerySort(this); 226 | private groupBuilder = new PqlQueryGroup(this); 227 | private limitValue = 0; 228 | private offsetValue = 0; 229 | 230 | constructor(entity: PqlEntity) { 231 | this.entity = entity; 232 | } 233 | 234 | filter(): PqlQueryFilter { 235 | return this.filterBuilder; 236 | } 237 | 238 | groupBy(): PqlQueryGroup { 239 | return this.groupBuilder; 240 | } 241 | 242 | sortBy(): PqlQuerySort { 243 | return this.sortBuilder; 244 | } 245 | 246 | addProjectionField(field: string): PqlQuery { 247 | this.projectionFields.push(field); 248 | return this; 249 | } 250 | 251 | offset(offset: number): PqlQuery { 252 | this.offsetValue = offset; 253 | return this; 254 | } 255 | 256 | limit(limit: number): PqlQuery { 257 | this.limitValue = limit; 258 | return this; 259 | } 260 | 261 | build(): string { 262 | let projection = ''; 263 | 264 | if (this.projectionFields.length > 0) { 265 | projection = `[${this.projectionFields.join(',')}]`; 266 | } 267 | 268 | let limit = ''; 269 | if (this.limitValue > 0) { 270 | limit = ` limit ${this.limitValue}`; 271 | } 272 | 273 | let offset = ''; 274 | if (this.offsetValue > 0) { 275 | offset = ` offset ${this.offsetValue}`; 276 | } 277 | 278 | const query = `${ 279 | this.entity 280 | } ${projection} { ${this.filterBuilder.build()}${this.groupBuilder.build()}${this.sortBuilder.build()}${limit}${offset}}`; 281 | 282 | console.log('build query: ', query) 283 | return query 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /ui/quasar.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | /* 4 | * This file runs in a Node context (it's NOT transpiled by Babel), so use only 5 | * the ES6 features that are supported by your Node version. https://node.green/ 6 | */ 7 | 8 | // Configuration for your app 9 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js 10 | 11 | import { defineConfig } from '#q-app/wrappers'; 12 | import { fileURLToPath } from 'url'; 13 | 14 | export default defineConfig((ctx) => { 15 | return { 16 | // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature 17 | // preFetch: true, 18 | 19 | // app boot file (/src/boot) 20 | // --> boot files are part of "main.js" 21 | // https://v2.quasar.dev/quasar-cli-vite/boot-files 22 | boot: ['i18n', 'axios'], 23 | 24 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css 25 | css: ['app.scss'], 26 | 27 | // https://github.com/quasarframework/quasar/tree/dev/extras 28 | extras: [ 29 | // 'ionicons-v4', 30 | // 'mdi-v7', 31 | // 'fontawesome-v6', 32 | // 'eva-icons', 33 | // 'themify', 34 | // 'line-awesome', 35 | // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both! 36 | 37 | 'roboto-font', // optional, you are not bound to it 38 | 'material-icons', // optional, you are not bound to it 39 | ], 40 | 41 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build 42 | build: { 43 | typescript: { 44 | strict: true, // (recommended) enables strict settings for TypeScript 45 | vueShim: true, // required when using ESLint with type-checked rules, will generate a shim file for `*.vue` files 46 | }, 47 | 48 | target: { 49 | browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'], 50 | node: 'node20', 51 | }, 52 | 53 | vueRouterMode: 'hash', // available values: 'hash', 'history' 54 | // vueRouterBase, 55 | // vueDevtools, 56 | // vueOptionsAPI: false, 57 | 58 | // rebuildCache: true, // rebuilds Vite/linter/etc cache on startup 59 | 60 | publicPath: '/ui/', 61 | // analyze: true, 62 | // env: {}, 63 | // rawDefine: {} 64 | // ignorePublicFolder: true, 65 | // minify: false, 66 | // polyfillModulePreload: true, 67 | // distDir 68 | 69 | // extendViteConf (viteConf) {}, 70 | // viteVuePluginOptions: {}, 71 | env: { 72 | VUE_APP_BACKEND_BASE_ADDRESS: process.env.VUE_APP_BACKEND_BASE_ADDRESS, 73 | }, 74 | vitePlugins: [ 75 | [ 76 | '@intlify/unplugin-vue-i18n/vite', 77 | { 78 | // if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false` 79 | // compositionOnly: false, 80 | 81 | // if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}', 82 | // you need to set `runtimeOnly: false` 83 | // runtimeOnly: false, 84 | 85 | ssr: ctx.modeName === 'ssr', 86 | 87 | // you need to set i18n resource including paths ! 88 | include: [fileURLToPath(new URL('./src/i18n', import.meta.url))], 89 | }, 90 | ], 91 | ['vite-plugin-checker', { 92 | vueTsc: true, 93 | eslint: { 94 | lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"', 95 | useFlatConfig: true 96 | } 97 | }, { server: false }] 98 | ], 99 | 100 | extendViteConf(viteConf) { 101 | viteConf.build ??= {} 102 | viteConf.build.rollupOptions ??= {} 103 | viteConf.build.rollupOptions.output = { 104 | sanitizeFileName(name) { 105 | // eslint-disable-next-line no-control-regex 106 | return name.replaceAll(/[\u0000-\u001F"*/:<>?\\|]/g, '-') 107 | } 108 | } 109 | } 110 | }, 111 | 112 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer 113 | devServer: { 114 | // https: true 115 | open: false, // opens browser window automatically 116 | }, 117 | 118 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework 119 | framework: { 120 | config: { 121 | dark: {}, 122 | notify: {}, 123 | }, 124 | 125 | // iconSet: 'material-icons', // Quasar icon set 126 | // lang: 'en-US', // Quasar language pack 127 | 128 | // For special cases outside of where the auto-import strategy can have an impact 129 | // (like functional components as one of the examples), 130 | // you can manually specify Quasar components/directives to be available everywhere: 131 | // 132 | // components: [], 133 | // directives: [], 134 | 135 | // Quasar plugins 136 | plugins: ['Notify'], 137 | }, 138 | 139 | // animations: 'all', // --- includes all animations 140 | // https://v2.quasar.dev/options/animations 141 | animations: [], 142 | 143 | // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#sourcefiles 144 | // sourceFiles: { 145 | // rootComponent: 'src/App.vue', 146 | // router: 'src/router/index', 147 | // store: 'src/store/index', 148 | // registerServiceWorker: 'src-pwa/register-service-worker', 149 | // serviceWorker: 'src-pwa/custom-service-worker', 150 | // pwaManifestFile: 'src-pwa/manifest.json', 151 | // electronMain: 'src-electron/electron-main', 152 | // electronPreload: 'src-electron/electron-preload' 153 | // }, 154 | 155 | // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr 156 | ssr: { 157 | // ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name! 158 | // will mess up SSR 159 | 160 | // extendSSRWebserverConf (esbuildConf) {}, 161 | // extendPackageJson (json) {}, 162 | 163 | pwa: false, 164 | 165 | // manualStoreHydration: true, 166 | // manualPostHydrationTrigger: true, 167 | 168 | prodPort: 3000, // The default port that the production server should use 169 | // (gets superseded if process.env.PORT is specified at runtime) 170 | 171 | middlewares: [ 172 | 'render', // keep this as last one 173 | ], 174 | }, 175 | 176 | // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa 177 | pwa: { 178 | workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' 179 | // swFilename: 'sw.js', 180 | // manifestFilename: 'manifest.json', 181 | // extendManifestJson (json) {}, 182 | // useCredentialsForManifestTag: true, 183 | // injectPwaMetaTags: false, 184 | // extendPWACustomSWConf (esbuildConf) {}, 185 | // extendGenerateSWOptions (cfg) {}, 186 | // extendInjectManifestOptions (cfg) {} 187 | }, 188 | 189 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova 190 | cordova: { 191 | // noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing 192 | }, 193 | 194 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor 195 | capacitor: { 196 | hideSplashscreen: true, 197 | }, 198 | 199 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron 200 | electron: { 201 | // extendElectronMainConf (esbuildConf) 202 | // extendElectronPreloadConf (esbuildConf) 203 | 204 | inspectPort: 5858, 205 | 206 | bundler: 'packager', // 'packager' or 'builder' 207 | 208 | packager: { 209 | // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options 210 | // OS X / Mac App Store 211 | // appBundleId: '', 212 | // appCategoryType: '', 213 | // osxSign: '', 214 | // protocol: 'myapp://path', 215 | // Windows only 216 | // win32metadata: { ... } 217 | }, 218 | 219 | builder: { 220 | // https://www.electron.build/configuration/configuration 221 | 222 | appId: 'openvoxview-ui', 223 | }, 224 | }, 225 | 226 | // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex 227 | bex: { 228 | contentScripts: ['my-content-script'], 229 | 230 | // extendBexScriptsConf (esbuildConf) {} 231 | // extendBexManifestJson (json) {} 232 | }, 233 | }; 234 | }); 235 | -------------------------------------------------------------------------------- /ui/src/pages/node/NodeDetailPage.vue: -------------------------------------------------------------------------------- 1 | 120 | 121 | 234 | 235 | 237 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= 2 | github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= 3 | github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 4 | github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 5 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= 6 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 11 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 12 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 13 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 14 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 15 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 16 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 17 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 18 | github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= 19 | github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= 20 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 21 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 22 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 23 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 24 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 25 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 26 | github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= 27 | github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 28 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 29 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 30 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 31 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 32 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 33 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 34 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 35 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 36 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 37 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 38 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 39 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 40 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 41 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 42 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 45 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 46 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 47 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 48 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 49 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 51 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 52 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 53 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 54 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 55 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 59 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 60 | github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= 61 | github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 62 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 63 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 64 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 65 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 66 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 67 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 68 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 69 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 70 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 71 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 72 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 73 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 74 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 75 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 76 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 77 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 78 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 79 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 80 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 81 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 82 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 83 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 84 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 85 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 86 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 87 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 88 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 89 | github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= 90 | github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 91 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 92 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 93 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 94 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 95 | golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= 96 | golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 97 | golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 98 | golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 99 | golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= 100 | golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 101 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 102 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 103 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 104 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 105 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 107 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 108 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 109 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 110 | golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 111 | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 112 | google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= 113 | google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 114 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 115 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 116 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 118 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 119 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------