diff --git a/dashboard/.gitignore b/dashboard/.gitignore
index 08d4cb887..c99cbb3e6 100644
--- a/dashboard/.gitignore
+++ b/dashboard/.gitignore
@@ -1,4 +1,6 @@
node_modules
dist
+yarn-error.log
+
!.vscode
diff --git a/dashboard/biome.json b/dashboard/biome.json
index 44574229e..c7e77577e 100644
--- a/dashboard/biome.json
+++ b/dashboard/biome.json
@@ -1,9 +1,9 @@
{
- "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json",
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
- "indentSize": 2,
+ "indentWidth": 2,
"lineWidth": 120
},
"javascript": {
@@ -16,10 +16,16 @@
"linter": {
"enabled": true,
"rules": {
- "recommended": true
+ "recommended": true,
+ "nursery": {
+ "noUnusedImports": "error"
+ },
+ "style": {
+ "noNonNullAssertion": "warn"
+ }
}
},
"files": {
- "ignore": ["src/api/*"]
+ "ignore": ["src/api/*", "package.json"]
}
}
\ No newline at end of file
diff --git a/dashboard/index.html b/dashboard/index.html
index 7e2a7c1fa..404397e9a 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -4,7 +4,6 @@
-
NeoShowcase
diff --git a/dashboard/package.json b/dashboard/package.json
index 591a29bab..fe02788d8 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -12,34 +12,41 @@
"lint:force": "biome check src --apply-unsafe",
"fix": "yarn fmt:apply && yarn lint:force",
"typecheck": "tsc --noEmit",
- "ci": "biome ci src"
+ "ci": "biome ci src",
+ "analyze": "vite build --mode analyze"
},
"license": "MIT",
"devDependencies": {
- "@biomejs/biome": "1.2.2",
+ "@biomejs/biome": "1.3.3",
"@macaron-css/core": "1.2.0",
"@macaron-css/solid": "1.4.1",
"@macaron-css/vite": "1.4.3",
- "@types/node": "20.8.0",
- "typescript": "5.2.2",
- "vite": "4.4.9",
+ "@tanstack/virtual-core": "^3.0.0-alpha.1",
+ "@types/node": "20.9.4",
+ "rollup-plugin-visualizer": "^5.9.3",
+ "typescript": "5.3.2",
+ "unplugin-fonts": "^1.0.3",
+ "vite": "5.0.2",
"vite-plugin-compression": "0.5.1",
- "vite-plugin-fonts": "0.7.0",
- "vite-plugin-solid": "2.7.0",
- "vite-plugin-solid-svg": "0.6.1"
+ "vite-plugin-solid": "2.7.2",
+ "vite-plugin-solid-svg": "0.7.0"
},
"dependencies": {
- "@bufbuild/protobuf": "1.4.1",
- "@connectrpc/connect": "1.1.2",
- "@connectrpc/connect-web": "1.1.2",
+ "@bufbuild/protobuf": "1.4.2",
+ "@connectrpc/connect": "1.1.3",
+ "@connectrpc/connect-web": "1.1.3",
+ "@kobalte/core": "^0.11.2",
+ "@modular-forms/solid": "^0.20.0",
"@solid-primitives/refs": "1.0.5",
- "@solidjs/router": "0.8.3",
+ "@solidjs/meta": "^0.29.1",
+ "@solidjs/router": "0.9.1",
+ "@tanstack/solid-virtual": "^3.0.0-beta.6",
"ansi-to-html": "0.7.2",
"chart.js": "4.4.0",
- "fuse.js": "6.6.2",
+ "fuse.js": "7.0.0",
"solid-chartjs": "1.3.8",
- "solid-icons": "1.0.12",
- "solid-js": "1.7.12",
+ "solid-icons": "1.1.0",
+ "solid-js": "1.8.6",
"solid-tippy": "0.2.1",
"solid-toast": "0.5.0",
"tippy.js": "6.3.7"
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index f96fceb00..c9c433c9d 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -1,21 +1,29 @@
-import Routes from '/@/routes'
+import { MetaProvider, Title } from '@solidjs/meta'
import { Router } from '@solidjs/router'
-import type { Component } from 'solid-js'
+import { type Component, ErrorBoundary } from 'solid-js'
import { Toaster } from 'solid-toast'
+import Routes from '/@/routes'
+import ErrorView from './components/layouts/ErrorView'
+import { WithHeader } from './components/layouts/WithHeader'
const App: Component = () => {
return (
- <>
+
+ NeoShowcase
-
+
+ }>
+
+
+
- >
+
)
}
diff --git a/dashboard/src/assets/icons/appState/deploying.svg b/dashboard/src/assets/icons/appState/deploying.svg
new file mode 100644
index 000000000..ffa0e51ac
--- /dev/null
+++ b/dashboard/src/assets/icons/appState/deploying.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/assets/icons/appState/error.svg b/dashboard/src/assets/icons/appState/error.svg
new file mode 100644
index 000000000..69e5874dd
--- /dev/null
+++ b/dashboard/src/assets/icons/appState/error.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/assets/icons/appState/failed.svg b/dashboard/src/assets/icons/appState/failed.svg
new file mode 100644
index 000000000..a542a15f9
--- /dev/null
+++ b/dashboard/src/assets/icons/appState/failed.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/assets/icons/appState/idle.svg b/dashboard/src/assets/icons/appState/idle.svg
new file mode 100644
index 000000000..a4bec4f58
--- /dev/null
+++ b/dashboard/src/assets/icons/appState/idle.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/assets/icons/appState/running.svg b/dashboard/src/assets/icons/appState/running.svg
new file mode 100644
index 000000000..f046f6f5a
--- /dev/null
+++ b/dashboard/src/assets/icons/appState/running.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/assets/icons/check.svg b/dashboard/src/assets/icons/check.svg
new file mode 100644
index 000000000..76d69811e
--- /dev/null
+++ b/dashboard/src/assets/icons/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/dashboard/src/assets/logo.svg b/dashboard/src/assets/logo.svg
new file mode 100644
index 000000000..27ff22845
--- /dev/null
+++ b/dashboard/src/assets/logo.svg
@@ -0,0 +1,22 @@
+
diff --git a/dashboard/src/assets/logo_small.svg b/dashboard/src/assets/logo_small.svg
new file mode 100644
index 000000000..49c5229d8
--- /dev/null
+++ b/dashboard/src/assets/logo_small.svg
@@ -0,0 +1,8 @@
+
diff --git a/dashboard/src/components/AppNav.tsx b/dashboard/src/components/AppNav.tsx
deleted file mode 100644
index dbcdd23c4..000000000
--- a/dashboard/src/components/AppNav.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
-import { CenterInline } from '/@/libs/layout'
-import { A } from '@solidjs/router'
-import { JSXElement } from 'solid-js'
-import {
- NavAnchorActiveStyle,
- NavAnchorStyle,
- NavButtonsContainer,
- NavContainer,
- NavTitle,
- NavTitleContainer,
-} from './Nav'
-
-export interface AppNavProps {
- repo: Repository
- app: Application
-}
-
-export const AppNav = (props: AppNavProps): JSXElement => {
- return (
-
-
- {providerToIcon(repositoryURLToProvider(props.repo.url), 36)}
-
- {props.repo.name}
- /
- {props.app.name}
-
-
-
-
- General
-
-
- Builds
-
-
- Settings
-
-
-
- )
-}
diff --git a/dashboard/src/components/AppRow.tsx b/dashboard/src/components/AppRow.tsx
deleted file mode 100644
index 2e8185be2..000000000
--- a/dashboard/src/components/AppRow.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Application } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { StatusIcon } from '/@/components/StatusIcon'
-import { applicationState } from '/@/libs/application'
-import { DiffHuman, shortSha } from '/@/libs/format'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { A } from '@solidjs/router'
-import { Component } from 'solid-js'
-
-const BorderContainer = styled('div', {
- base: {
- border: `1px solid ${vars.bg.white4}`,
- selectors: {
- '&:not(:last-child)': {
- borderBottom: 'none',
- },
- },
- },
-})
-
-const ApplicationContainer = styled('div', {
- base: {
- display: 'grid',
- gridTemplateColumns: '20px 1fr',
- gap: '8px',
- padding: '12px 20px',
-
- backgroundColor: vars.bg.white1,
- },
-})
-
-const AppDetail = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '4px',
- },
-})
-
-const AppName = styled('div', {
- base: {
- fontSize: '14px',
- fontWeight: 500,
- color: vars.text.black1,
- },
-})
-
-const AppFooter = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- justifyContent: 'space-between',
- width: '100%',
-
- fontSize: '11px',
- color: vars.text.black3,
- },
-})
-
-const AppFooterRight = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '48px',
- },
-})
-
-export interface AppRowProps {
- app: Application
-}
-
-const AppRow: Component = (props) => {
- return (
-
-
-
-
-
- {props.app.name}
-
- {shortSha(props.app.commit)}
-
- {props.app.websites[0]?.fqdn || ''}
-
-
-
-
-
-
-
- )
-}
-
-export default AppRow
diff --git a/dashboard/src/components/AppStatus.tsx b/dashboard/src/components/AppStatus.tsx
deleted file mode 100644
index 2b901caee..000000000
--- a/dashboard/src/components/AppStatus.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Application } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { StatusIcon } from '/@/components/StatusIcon'
-import { ApplicationState, applicationState } from '/@/libs/application'
-import { styled } from '@macaron-css/solid'
-import { JSX } from 'solid-js'
-
-const Container = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- width: '100%',
- },
-})
-
-const ContainerLeft = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '8px',
- alignItems: 'center',
- },
-})
-
-interface AppStatusProps {
- apps: Application[] | undefined
- state: ApplicationState
-}
-
-export const AppStatus = (props: AppStatusProps): JSX.Element => {
- const num = () => props.apps?.filter((app) => applicationState(app) === props.state)?.length ?? 0
- return (
-
-
-
- {props.state}
-
- {num()}
-
- )
-}
diff --git a/dashboard/src/components/AppsNew.tsx b/dashboard/src/components/AppsNew.tsx
deleted file mode 100644
index 0c3b170df..000000000
--- a/dashboard/src/components/AppsNew.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-
-export const FormButton = styled('div', {
- base: {
- marginLeft: '8px',
- },
-})
-
-export const FormTextBig = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '4px',
-
- fontSize: '20px',
- fontWeight: 900,
- color: vars.text.black1,
-
- marginBottom: '4px',
- },
-})
-
-export const FormCheckBox = styled('div', {
- base: {
- background: vars.bg.white1,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- marginLeft: '4px',
- padding: '8px 12px',
-
- width: '320px',
- },
-})
-
-export const SettingsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '10px',
- },
-})
-
-export const FormSettings = styled('div', {
- base: {
- background: vars.bg.white2,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- marginLeft: '4px',
- padding: '8px 12px',
-
- display: 'flex',
- flexDirection: 'column',
- gap: '12px',
- },
-})
-
-export const FormSettingsButton = styled('div', {
- base: {
- display: 'flex',
- gap: '8px',
- marginBottom: '4px',
- },
-})
diff --git a/dashboard/src/components/ArtifactRow.tsx b/dashboard/src/components/ArtifactRow.tsx
deleted file mode 100644
index 0132cdcaf..000000000
--- a/dashboard/src/components/ArtifactRow.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { Artifact } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { IconButton } from '/@/components/IconButton'
-import { client, handleAPIError } from '/@/libs/api'
-import { DiffHuman, formatBytes } from '/@/libs/format'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { AiOutlineDownload } from 'solid-icons/ai'
-import { Component, Show } from 'solid-js'
-
-const BorderContainer = styled('div', {
- base: {
- border: `1px solid ${vars.bg.white4}`,
- selectors: {
- '&:not(:last-child)': {
- borderBottom: 'none',
- },
- },
- },
-})
-
-const ApplicationContainer = styled('div', {
- base: {
- display: 'grid',
- gridTemplateColumns: '1fr auto',
- gap: '24px',
- padding: '12px 20px',
- alignItems: 'center',
-
- backgroundColor: vars.bg.white1,
- },
-})
-
-const AppDetail = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '4px',
- },
-})
-
-const AppName = styled('div', {
- base: {
- fontSize: '14px',
- fontWeight: 500,
- color: vars.text.black1,
- },
-})
-
-const AppFooter = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '40px',
- justifyContent: 'space-between',
- width: '100%',
-
- fontSize: '11px',
- color: vars.text.black3,
- },
-})
-
-const AppFooterPadding = styled('div', {
- base: {
- width: '80px',
- },
-})
-
-const downloadArtifact = async (id: string) => {
- try {
- const data = await client.getBuildArtifact({ artifactId: id })
- const dataBlob = new Blob([data.content], { type: 'application/gzip' })
- const blobUrl = URL.createObjectURL(dataBlob)
- const anchor = document.createElement('a')
- anchor.href = blobUrl
- anchor.download = data.filename
- anchor.click()
- URL.revokeObjectURL(blobUrl)
- } catch (e) {
- handleAPIError(e, '成果物のダウンロードに失敗しました')
- }
-}
-
-export interface ArtifactRowProps {
- artifact: Artifact
-}
-
-export const ArtifactRow: Component = (props) => {
- return (
-
-
-
- {props.artifact.name}
-
- {formatBytes(+props.artifact.size.toString())}
-
-
-
-
-
-
-
- }
- >
- downloadArtifact(props.artifact.id)}>
-
-
-
-
-
- )
-}
diff --git a/dashboard/src/components/BuildConfigs.tsx b/dashboard/src/components/BuildConfigs.tsx
deleted file mode 100644
index 79a14b087..000000000
--- a/dashboard/src/components/BuildConfigs.tsx
+++ /dev/null
@@ -1,343 +0,0 @@
-import { ApplicationConfig, RuntimeConfig, StaticConfig } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { FormCheckBox, FormSettings } from '/@/components/AppsNew'
-import { Checkbox } from '/@/components/Checkbox'
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { InputArea, InputBar, InputLabel } from '/@/components/Input'
-import { RadioItem } from '/@/components/Radio'
-import { Select } from '/@/components/Select'
-import { PlainMessage } from '@bufbuild/protobuf'
-import { Match, Switch, createEffect } from 'solid-js'
-import { SetStoreFunction } from 'solid-js/store'
-
-export type BuildConfigMethod = ApplicationConfig['buildConfig']['case']
-const buildConfigItems: RadioItem[] = [
- { value: 'runtimeBuildpack', title: 'Runtime Buildpack' },
- { value: 'runtimeCmd', title: 'Runtime Command' },
- { value: 'runtimeDockerfile', title: 'Runtime Dockerfile' },
- { value: 'staticBuildpack', title: 'Static Buildpack' },
- { value: 'staticCmd', title: 'Static Command' },
- { value: 'staticDockerfile', title: 'Static Dockerfile' },
-]
-
-interface RuntimeConfigProps {
- // case, valueのunionを直接使っている都合上、staticからruntimeに切り替えたときにruntimeConfigフィールドが存在しない
- runtimeConfig: PlainMessage | undefined
- setRuntimeConfig: >(k: K, v: PlainMessage[K]) => void
-}
-
-const RuntimeConfigs = (props: RuntimeConfigProps) => {
- return (
- <>
-
-
- Database
-
-
-
- props.setRuntimeConfig('useMariadb', useMariadb)}
- >
- MariaDB
-
- props.setRuntimeConfig('useMongodb', useMongodb)}
- >
- MongoDB
-
-
-
-
-
- Entrypoint
-
-
- props.setRuntimeConfig('entrypoint', e.target.value)}
- />
-
-
-
- Command
-
-
- props.setRuntimeConfig('command', e.target.value)}
- />
-
- >
- )
-}
-
-interface StaticConfigProps {
- // case, valueのunionを直接使っている都合上、runtimeからstaticに切り替えたときにstaticConfigフィールドが存在しない
- staticConfig: PlainMessage | undefined
- setStaticConfig: >(k: K, v: PlainMessage[K]) => void
-}
-
-const StaticConfigs = (props: StaticConfigProps) => {
- return (
- <>
-
-
- Artifact path
-
-
- props.setStaticConfig('artifactPath', e.target.value)}
- />
-
-
-
- SPA
-
-
-
- props.setStaticConfig('spa', selected)}
- >
- SPA (Single Page Application)
-
-
-
- >
- )
-}
-
-export interface BuildConfigsProps {
- buildConfig: PlainMessage['buildConfig']
- setBuildConfig: SetStoreFunction['buildConfig']>
-}
-
-export const BuildConfigs = (props: BuildConfigsProps) => {
- createEffect(() => {
- // @ts-ignore
- if (!props.buildConfig.value.runtimeConfig) {
- // @ts-ignore
- props.setBuildConfig('value', 'runtimeConfig', structuredClone(new RuntimeConfig()))
- }
- })
- createEffect(() => {
- // @ts-ignore
- if (!props.buildConfig.value.staticConfig) {
- // @ts-ignore
- props.setBuildConfig('value', 'staticConfig', structuredClone(new StaticConfig()))
- }
- })
-
- return (
-
-
-
- Type
-
-
-
-
-
-
- {(v) => (
- <>
-
-
- Context
-
-
- props.setBuildConfig('value', { context: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'runtimeConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- {(v) => (
- <>
-
-
- Base image
-
-
- props.setBuildConfig('value', { baseImage: e.target.value })}
- />
-
-
-
- Build command
-
-
- props.setBuildConfig('value', { buildCmd: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'runtimeConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- {(v) => (
- <>
-
-
- Dockerfile name
-
-
- props.setBuildConfig('value', { dockerfileName: e.target.value })}
- />
-
-
-
- Context
-
-
- props.setBuildConfig('value', { context: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'runtimeConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- {(v) => (
- <>
-
-
- Context
-
-
- props.setBuildConfig('value', { context: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'staticConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- {(v) => (
- <>
-
-
- Base image
-
-
- props.setBuildConfig('value', { baseImage: e.target.value })}
- />
-
-
-
- Build command
-
-
- props.setBuildConfig('value', { buildCmd: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'staticConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- {(v) => (
- <>
-
- Dockerfile name
- props.setBuildConfig('value', { dockerfileName: e.target.value })}
- />
-
-
- Context
- props.setBuildConfig('value', { context: e.target.value })}
- />
-
- {
- // @ts-ignore
- props.setBuildConfig('value', 'staticConfig', { [k]: v })
- }}
- />
- >
- )}
-
-
-
- )
-}
diff --git a/dashboard/src/components/BuildList.tsx b/dashboard/src/components/BuildList.tsx
deleted file mode 100644
index 850c7caae..000000000
--- a/dashboard/src/components/BuildList.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Build } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { BuildStatusIcon } from '/@/components/BuildStatusIcon'
-import { DiffHuman, shortSha } from '/@/libs/format'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { A } from '@solidjs/router'
-import { Component, For } from 'solid-js'
-
-const BuildsContainer = styled('div', {
- base: {
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- },
-})
-
-const BuildContainer = styled('div', {
- base: {
- display: 'grid',
- gridTemplateColumns: '20px 1fr',
- gap: '8px',
- padding: '12px 20px',
-
- backgroundColor: vars.bg.white1,
- },
- variants: {
- upperBorder: {
- none: {},
- line: {
- borderWidth: '1px 0',
- borderStyle: 'solid',
- borderColor: vars.bg.white4,
- },
- },
- },
-})
-
-const BuildDetail = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '4px',
- },
-})
-
-const BuildName = styled('div', {
- base: {
- fontSize: '14px',
- fontWeight: 500,
- color: vars.text.black1,
- },
-})
-
-const BuildFooter = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- justifyContent: 'space-between',
- width: '100%',
-
- fontSize: '11px',
- color: vars.text.black3,
- },
-})
-
-const BuildFooterRight = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '48px',
- },
-})
-
-export interface BuildListProps {
- builds: Build[]
- showAppID: boolean
-}
-
-export const BuildList: Component = (props) => {
- return (
-
-
- {(b, i) => (
-
- 0 && i() < props.builds.length - 1 ? 'line' : 'none'}>
-
-
-
- Build {b.id}
- {props.showAppID && ` (App ${b.applicationId})`}
-
-
- {shortSha(b.commit)}
-
-
-
-
-
-
-
- )}
-
-
- )
-}
diff --git a/dashboard/src/components/BuildStatusIcon.tsx b/dashboard/src/components/BuildStatusIcon.tsx
deleted file mode 100644
index b85971644..000000000
--- a/dashboard/src/components/BuildStatusIcon.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { BuildStatus } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { vars } from '/@/theme'
-import { AiFillCheckCircle, AiFillExclamationCircle, AiFillMinusCircle } from 'solid-icons/ai'
-import { IoReloadCircle } from 'solid-icons/io'
-import { JSXElement } from 'solid-js'
-import { Dynamic } from 'solid-js/web'
-
-interface IconProps {
- size: number
-}
-const components: Record JSXElement> = {
- [BuildStatus.QUEUED]: (props) => ,
- [BuildStatus.BUILDING]: (props) => ,
- [BuildStatus.SUCCEEDED]: (props) => ,
- [BuildStatus.FAILED]: (props) => ,
- [BuildStatus.CANCELLED]: (props) => ,
- [BuildStatus.SKIPPED]: (props) => ,
-}
-
-interface Props {
- state: BuildStatus
- size?: number
-}
-
-export const BuildStatusIcon = (props: Props): JSXElement => {
- return
-}
diff --git a/dashboard/src/components/Button.tsx b/dashboard/src/components/Button.tsx
deleted file mode 100644
index a18f79612..000000000
--- a/dashboard/src/components/Button.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { JSX, ParentComponent, splitProps } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
-
-const Container = styled('button', {
- base: {
- display: 'flex',
- borderRadius: '4px',
- minWidth: '100px',
- padding: '8px 16px',
- },
- variants: {
- cursor: {
- none: {},
- pointer: {
- cursor: 'pointer',
- },
- },
- color: {
- black1: {
- backgroundColor: vars.bg.black1,
- '&:disabled': {
- backgroundColor: vars.text.black4,
- },
- },
- },
- width: {
- auto: {
- width: 'fit-content',
- },
- full: {
- width: '100%',
- },
- },
- },
-})
-
-const Text = styled('div', {
- base: {
- margin: 'auto',
- fontSize: '16px',
- fontWeight: 'bold',
- },
- variants: {
- color: {
- black1: {
- color: vars.text.white1,
- },
- },
- size: {
- large: {
- fontSize: '16px',
- fontWeight: 'bold',
- },
- },
- },
-})
-
-export interface Props extends JSX.ButtonHTMLAttributes {
- color: 'black1'
- size: 'large'
- width: 'auto' | 'full'
- tooltip?: string
-}
-
-export const Button: ParentComponent = (props) => {
- const [addedProps, originalButtonProps] = splitProps(props, ['color', 'size', 'width'])
-
- const cursor = () => (originalButtonProps.onclick !== undefined && !originalButtonProps.disabled ? 'pointer' : 'none')
- return (
-
-
-
- {props.children}
-
-
-
- )
-}
diff --git a/dashboard/src/components/Card.tsx b/dashboard/src/components/Card.tsx
deleted file mode 100644
index c8d6dae66..000000000
--- a/dashboard/src/components/Card.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-
-export const CardRowsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '40px',
- },
-})
-
-export const CardsRow = styled('div', {
- base: {
- display: 'flex',
- flex: '0 1 auto',
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: '40px',
- },
-})
-
-export const Card = styled('div', {
- base: {
- minWidth: '320px',
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- background: vars.bg.white1,
- padding: '24px 36px',
-
- display: 'flex',
- flexDirection: 'column',
- gap: '24px',
- },
-})
-
-export const CardTitle = styled('div', {
- base: {
- fontSize: '24px',
- fontWeight: 600,
- },
-})
-
-export const CardItems = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '20px',
- },
-})
-
-export const CardItem = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '8px',
- },
-})
-
-export const CardItemTitle = styled('div', {
- base: {
- fontSize: '16px',
- color: vars.text.black3,
- },
-})
-
-export const CardItemContent = styled('div', {
- base: {
- marginLeft: 'auto',
- fontSize: '16px',
- color: vars.text.black1,
-
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '4px',
- },
-})
diff --git a/dashboard/src/components/Checkbox.tsx b/dashboard/src/components/Checkbox.tsx
deleted file mode 100644
index 5719da07f..000000000
--- a/dashboard/src/components/Checkbox.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { ImCheckboxChecked, ImCheckboxUnchecked } from 'solid-icons/im'
-import { ParentComponent } from 'solid-js'
-
-const Container = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '12px',
- cursor: 'pointer',
- alignItems: 'center',
- width: '100%',
- },
-})
-
-interface Props {
- selected: boolean
- setSelected: (s: boolean) => void
- onClick?: () => void
-}
-
-export const Checkbox: ParentComponent = (props) => {
- return (
- {
- props.setSelected(!props.selected)
- props.onClick?.()
- }}
- >
- {props.selected ? (
-
- ) : (
-
- )}
- {props.children}
-
- )
-}
diff --git a/dashboard/src/components/Header.tsx b/dashboard/src/components/Header.tsx
deleted file mode 100644
index f519706ca..000000000
--- a/dashboard/src/components/Header.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { systemInfo, user } from '/@/libs/api'
-import { vars } from '/@/theme'
-import { style } from '@macaron-css/core'
-import { styled } from '@macaron-css/solid'
-import { A } from '@solidjs/router'
-import { JSXElement } from 'solid-js'
-import Logo from '../images/logo.svg'
-
-const Container = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
-
- padding: '20px 36px',
- backgroundColor: vars.bg.black1,
- borderRadius: '16px',
- fontFamily: 'Mulish',
- },
-})
-
-const LeftContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '72px',
- },
-})
-
-const NavContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '48px',
- alignItems: 'center',
-
- fontSize: '18px',
- },
-})
-
-const navActive = style({
- color: vars.text.white1,
-})
-const navInactive = style({
- color: vars.text.black4,
-})
-
-interface NavProps {
- href: string
- children: JSXElement
-}
-const Nav = ({ href, children }: NavProps): JSXElement => {
- return (
-
- {children}
-
- )
-}
-
-const RightContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- },
-})
-
-const UserIcon = styled('img', {
- base: {
- borderRadius: '100%',
- height: '48px',
- width: '48px',
- },
-})
-
-const UserName = styled('div', {
- base: {
- color: vars.text.white1,
- fontSize: '20px',
- marginLeft: '20px',
- },
-})
-
-export const Header = (): JSXElement => {
- return (
-
-
-
-
-
-
-
-
- ADMINER
-
-
-
-
- {user() && }
- {user() && {user().name}}
-
-
- )
-}
diff --git a/dashboard/src/components/IconButton.tsx b/dashboard/src/components/IconButton.tsx
deleted file mode 100644
index 5e1135330..000000000
--- a/dashboard/src/components/IconButton.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { FlowComponent } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
-
-const Container = styled('div', {
- base: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
-
- width: '32px',
- height: '32px',
- padding: '4px',
- borderRadius: '4px',
- },
- variants: {
- cursor: {
- none: {},
- pointer: {
- cursor: 'pointer',
- },
- },
- color: {
- normal: {
- border: `1px solid ${vars.text.black4}`,
- backgroundColor: vars.bg.white2,
- selectors: {
- '&:hover': {
- backgroundColor: vars.bg.white5,
- },
- },
- },
- disabled: {
- border: `1px solid ${vars.text.black3}`,
- backgroundColor: vars.bg.white4,
- },
- },
- },
-})
-
-interface IconButtonProps {
- onClick?: () => void
- tooltip?: string
- disabled?: boolean
-}
-
-export const IconButton: FlowComponent = (props) => {
- return (
-
-
- {props.children}
-
-
- )
-}
diff --git a/dashboard/src/components/InfoTooltip.tsx b/dashboard/src/components/InfoTooltip.tsx
deleted file mode 100644
index e37c3f6fd..000000000
--- a/dashboard/src/components/InfoTooltip.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { styled } from '@macaron-css/solid'
-import { AiOutlineInfoCircle } from 'solid-icons/ai'
-import { Component, For, createMemo } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-import { Content } from 'tippy.js'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
-
-const Container = styled('div', {
- base: {
- position: 'relative',
-
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
-
- width: '20px',
- height: '20px',
- },
-})
-
-const TooltipContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- },
- variants: {
- align: {
- left: {
- alignItems: 'flex-start',
- },
- center: {
- alignItems: 'center',
- },
- },
- },
-})
-
-export interface InfoTooltipProps {
- tooltip: string | string[]
- style?: 'bullets' | 'bullets-with-title' | 'left' | 'center'
-}
-
-export const InfoTooltip: Component = (props) => {
- const content = createMemo((): Content => {
- if (typeof props.tooltip === 'string') return props.tooltip
- if (props.style === 'bullets-with-title') {
- return (
-
- {props.tooltip[0]}
-
-
- ) as Element
- }
- if (props.style === 'bullets') {
- return (
-
-
-
- ) as Element
- }
- return (
-
- {(line) => {line}}
-
- ) as Element
- })
-
- return (
-
- )
-}
diff --git a/dashboard/src/components/Input.tsx b/dashboard/src/components/Input.tsx
deleted file mode 100644
index 8297a6c90..000000000
--- a/dashboard/src/components/Input.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { vars } from '/@/theme'
-import { ComplexStyleRule } from '@macaron-css/core'
-import { styled } from '@macaron-css/solid'
-import { Component, JSX, splitProps } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
-
-export const InputLabel = styled('div', {
- base: {
- display: 'flex',
- alignItems: 'center',
- gap: '4px',
-
- fontSize: '16px',
- fontWeight: 700,
- color: vars.text.black1,
-
- marginBottom: '4px',
- },
-})
-
-const inputStyle: ComplexStyleRule = {
- padding: '8px 12px',
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- fontSize: '14px',
-
- width: '100%',
-
- display: 'flex',
- flexDirection: 'column',
-
- '::placeholder': {
- color: vars.text.black3,
- },
-}
-
-const StyledInput = styled('input', {
- base: {
- ...inputStyle,
- },
- variants: {
- width: {
- full: {
- width: '100%',
- },
- middle: {
- width: '320px',
- },
- short: {
- width: '160px',
- },
- tiny: {
- width: '80px',
- },
- },
- },
- defaultVariants: {
- width: 'full',
- },
-})
-
-interface InputBarProps extends JSX.InputHTMLAttributes {
- width?: 'full' | 'middle' | 'short' | 'tiny'
- tooltip?: string
-}
-
-export const InputBar: Component = (props) => {
- const [addedProps, inputProps] = splitProps(props, ['width'])
-
- return (
-
-
-
- )
-}
-
-const StyledInputArea = styled('textarea', {
- base: {
- ...inputStyle,
- minHeight: '100px',
- },
-})
-
-interface InputAreaProps extends JSX.TextareaHTMLAttributes {
- onInput: JSX.InputEventHandler
-}
-
-export const InputArea: Component = (props) => {
- const [addedProps, inputProps] = splitProps(props, ['onInput'])
-
- let ref: HTMLTextAreaElement
- const onInput: InputAreaProps['onInput'] = (e) => {
- ref.style.height = '100px'
- ref.style.height = `${ref.scrollHeight}px`
- addedProps?.onInput(e)
- }
-
- return
-}
diff --git a/dashboard/src/components/InputSuggestion.tsx b/dashboard/src/components/InputSuggestion.tsx
deleted file mode 100644
index 30cdf0d02..000000000
--- a/dashboard/src/components/InputSuggestion.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { clickInside as clickInsideDir, clickOutside as clickOutsideDir } from '/@/libs/useClickInout'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { FlowComponent, For, JSX, ParentComponent, Show, createSignal } from 'solid-js'
-
-// https://github.com/solidjs/solid/discussions/845
-const clickInside = clickInsideDir
-const clickOutside = clickOutsideDir
-
-const SuggestionOuterContainer = styled('div', {
- base: {
- position: 'relative',
- },
-})
-
-const SuggestionContainer = styled('div', {
- base: {
- position: 'absolute',
- width: '100%',
- maxHeight: '300px',
- overflowX: 'hidden',
- overflowY: 'scroll',
- zIndex: 1,
-
- display: 'flex',
- flexDirection: 'column',
- gap: '6px',
- backgroundColor: vars.bg.white1,
- borderRadius: '4px',
- border: `1px solid ${vars.bg.black1}`,
- padding: '8px',
- },
-})
-
-const Suggestion = styled('div', {
- base: {
- selectors: {
- '&:hover': {
- backgroundColor: vars.bg.white4,
- },
- },
- },
-})
-
-export interface InputSuggestionProps {
- suggestions: string[]
- onSetSuggestion: (selected: string) => void
-}
-
-export type InputSuggestionChildren = (onFocus: () => void) => JSX.Element
-
-export const InputSuggestion: FlowComponent = (props) => {
- const [focused, setFocused] = createSignal(false)
-
- return (
- setFocused(true)} use:clickOutside={() => setFocused(false)}>
- {props.children(() => setFocused(true))}
-
-
-
-
- {(b) => props.onSetSuggestion(b)}>{b}}
-
-
-
-
-
- )
-}
diff --git a/dashboard/src/components/Modal.tsx b/dashboard/src/components/Modal.tsx
deleted file mode 100644
index ba02b24c2..000000000
--- a/dashboard/src/components/Modal.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-
-export const ModalContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '24px',
- },
-})
-
-export const ModalButtonsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '16px',
- justifyContent: 'center',
- },
-})
-
-export const ModalText = styled('div', {
- base: {
- fontSize: '16px',
- fontWeight: 'bold',
- color: vars.text.black1,
- textAlign: 'center',
- },
-})
diff --git a/dashboard/src/components/Nav.tsx b/dashboard/src/components/Nav.tsx
deleted file mode 100644
index c48c298d9..000000000
--- a/dashboard/src/components/Nav.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { style } from '@macaron-css/core'
-import { styled } from '@macaron-css/solid'
-import { vars } from '../theme'
-
-export const NavContainer = styled('div', {
- base: {
- marginTop: '48px',
- marginBottom: '24px',
- },
-})
-
-export const NavTitleContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '14px',
- alignContent: 'center',
-
- fontSize: '32px',
- fontWeight: 'bold',
- color: vars.text.black1,
- },
-})
-
-export const NavTitle = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '8px',
- },
-})
-
-export const NavButtonsContainer = styled('nav', {
- base: {
- marginTop: '20px',
- display: 'flex',
- flexDirection: 'row',
- gap: '20px',
- },
-})
-
-export const NavAnchorStyle = style({
- fontSize: '24px',
- fontWeight: 'medium',
- color: vars.text.black3,
- textDecoration: 'none',
- padding: '4px 12px',
- selectors: {
- '&:hover': {
- color: vars.text.black2,
- },
- },
-})
-
-export const NavAnchorActiveStyle = style({
- color: vars.text.black1,
- borderBottom: `2px solid ${vars.text.black1}`,
-})
diff --git a/dashboard/src/components/PortPublications.tsx b/dashboard/src/components/PortPublications.tsx
deleted file mode 100644
index a37effa73..000000000
--- a/dashboard/src/components/PortPublications.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { PortPublication, PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { FormButton, FormSettings, FormSettingsButton, SettingsContainer } from '/@/components/AppsNew'
-import { Button } from '/@/components/Button'
-import { InputBar, InputLabel } from '/@/components/Input'
-import { Radio, RadioItem } from '/@/components/Radio'
-import { Select, SelectItem } from '/@/components/Select'
-import { pickRandom, randIntN } from '/@/libs/random'
-import { PlainMessage } from '@bufbuild/protobuf'
-import { styled } from '@macaron-css/solid'
-import { AiOutlinePlusCircle } from 'solid-icons/ai'
-import { FaRegularTrashCan } from 'solid-icons/fa'
-import { For } from 'solid-js'
-import { SetStoreFunction } from 'solid-js/store'
-import { systemInfo } from '../libs/api'
-import { portPublicationProtocolMap } from '../libs/application'
-import { vars } from '../theme'
-
-const AvailablePortContainer = styled('div', {
- base: {
- fontSize: '14px',
- color: vars.text.black2,
- padding: '8px',
- },
-})
-
-const AvailableDomainUl = styled('ul', {
- base: {
- margin: '8px 0',
- },
-})
-
-const PortVisualContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '2px',
- },
-})
-
-const protocolItems: SelectItem[] = [
- { value: PortPublicationProtocol.TCP, title: 'TCP' },
- { value: PortPublicationProtocol.UDP, title: 'UDP' },
-]
-
-const protoToName: Record = {
- [PortPublicationProtocol.TCP]: 'TCP',
- [PortPublicationProtocol.UDP]: 'UDP',
-}
-
-interface PortPublicationProps {
- port: PlainMessage
- setPort: >(
- valueName: T,
- value: PlainMessage[T],
- ) => void
- deletePort: () => void
-}
-
-const PortSetting = (props: PortPublicationProps) => {
- return (
-
-
- props.setPort('internetPort', +e.target.value)}
- width="tiny"
- tooltip="インターネット側ポート"
- />
- /
-
-
-
-
-
- )
-}
-
-const suggestPort = (proto: PortPublicationProtocol): number => {
- const available = systemInfo()?.ports.filter((a) => a.protocol === proto) || []
- if (available.length === 0) return 0
- const range = pickRandom(available)
- return randIntN(range.endPort + 1 - range.startPort) + range.startPort
-}
-
-const newPort = (): PlainMessage => {
- return {
- internetPort: suggestPort(PortPublicationProtocol.TCP),
- applicationPort: 0,
- protocol: PortPublicationProtocol.TCP,
- }
-}
-
-interface PortPublicationSettingsProps {
- ports: PlainMessage[]
- setPorts: SetStoreFunction[]>
-}
-
-export const PortPublicationSettings = (props: PortPublicationSettingsProps) => {
- return (
-
-
- 使用可能なポート
-
-
- {(port) => (
-
- {port.startPort}/{portPublicationProtocolMap[port.protocol]}~{port.endPort}/
- {portPublicationProtocolMap[port.protocol]}
-
- )}
-
-
-
-
- {(port, i) => (
- props.setPorts(i(), valueName, value)}
- deletePort={() => props.setPorts((current) => [...current.slice(0, i()), ...current.slice(i() + 1)])}
- />
- )}
-
-
-
-
-
-
- )
-}
diff --git a/dashboard/src/components/Radio.tsx b/dashboard/src/components/Radio.tsx
deleted file mode 100644
index 2c13b110e..000000000
--- a/dashboard/src/components/Radio.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { ImRadioChecked, ImRadioUnchecked } from 'solid-icons/im'
-import { For, JSXElement } from 'solid-js'
-
-const Container = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '12px',
-
- fontSize: '20px',
- color: vars.text.black1,
- },
-})
-
-const ItemContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '12px',
-
- cursor: 'pointer',
- alignItems: 'center',
- },
-})
-
-export interface RadioItem {
- value: T
- title: string
-}
-
-export interface Props {
- items: RadioItem[]
- selected: T
- setSelected: (s: T) => void
- onClick?: () => void
-}
-
-export const Radio = (props: Props): JSXElement => {
- return (
-
-
- {(item) => (
- {
- props.setSelected(item.value)
- props.onClick?.()
- }}
- >
- {props.selected === item.value ? (
-
- ) : (
-
- )}
- {item.title}
-
- )}
-
-
- )
-}
diff --git a/dashboard/src/components/RepositoryAuthSettings.tsx b/dashboard/src/components/RepositoryAuthSettings.tsx
deleted file mode 100644
index 2f2d7f527..000000000
--- a/dashboard/src/components/RepositoryAuthSettings.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { PlainMessage } from '@bufbuild/protobuf'
-import { styled } from '@macaron-css/solid'
-import { OcCopy2 } from 'solid-icons/oc'
-import { Component, Match, Show, Switch, createEffect, createResource, createSignal } from 'solid-js'
-import { SetStoreFunction } from 'solid-js/store'
-import { CreateRepositoryAuth } from '../api/neoshowcase/protobuf/gateway_pb'
-import { client, systemInfo } from '../libs/api'
-import { writeToClipboard } from '../libs/clipboard'
-import { vars } from '../theme'
-import { Button } from './Button'
-import { IconButton } from './IconButton'
-import { InputBar, InputLabel } from './Input'
-import { Radio } from './Radio'
-
-const SshDetails = styled('div', {
- base: {
- color: vars.text.black2,
- marginBottom: '4px',
- },
-})
-
-const PublicKeyCode = styled('code', {
- base: {
- display: 'block',
- padding: '8px 12px',
- fontSize: '14px',
- background: vars.bg.white2,
- color: vars.text.black1,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- },
-})
-
-const Row = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '8px',
- alignItems: 'center',
- },
-})
-
-interface RepositoryAuthSettingsProps {
- authConfig: PlainMessage
- setAuthConfig: SetStoreFunction>
-}
-
-export const RepositoryAuthSettings: Component = (props) => {
- const [useTmpKey, setUseTmpKey] = createSignal(false)
- const [tmpKey] = createResource(
- () => (useTmpKey() ? true : undefined),
- () => client.generateKeyPair({}),
- )
- createEffect(() => {
- if (!tmpKey()) return
- props.setAuthConfig('auth', 'value', { keyId: tmpKey()?.keyId })
- })
- const publicKey = () => (useTmpKey() ? tmpKey()?.publicKey : systemInfo()?.publicKey)
-
- const handleCopyPublicKey = () => writeToClipboard(publicKey())
-
- return (
-
-
認証方法
-
props.setAuthConfig('auth', 'case', v)}
- />
-
-
- {(v) => (
- <>
- ユーザー名
-
- props.setAuthConfig('auth', 'value', {
- username: e.currentTarget.value,
- })
- }
- />
- パスワード
-
- props.setAuthConfig('auth', 'value', {
- password: e.currentTarget.value,
- })
- }
- />
- >
- )}
-
-
-
- 以下のSSH公開鍵{!useTmpKey() && ' (システム共通) '}
- をリポジトリに登録してください。
-
-
- {publicKey()}
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/dashboard/src/components/RepositoryInfo.tsx b/dashboard/src/components/RepositoryInfo.tsx
deleted file mode 100644
index 01606b552..000000000
--- a/dashboard/src/components/RepositoryInfo.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { JSXElement } from 'solid-js'
-
-const RepositoryInfoContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '10px',
- },
-})
-
-const RepoName = styled('div', {
- base: {
- fontSize: '16px',
- fontWeight: 500,
- color: vars.text.black1,
- },
-})
-
-const RepositoryInfoBackground = styled('div', {
- base: {
- display: 'flex',
-
- background: vars.bg.white3,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- padding: '8px 12px',
- },
-})
-
-const SmallText = styled('div', {
- base: {
- display: 'flex',
- fontSize: '11px',
- color: vars.text.black3,
- },
-})
-
-export interface RepositoryInfoProps {
- repo: Repository
-}
-
-export const RepositoryInfo = (props: RepositoryInfoProps): JSXElement => {
- const provider = repositoryURLToProvider(props.repo.url)
- return (
-
-
- {providerToIcon(provider)}
- {props.repo.name}
- {props.repo.url}
-
-
- )
-}
diff --git a/dashboard/src/components/RepositoryNav.tsx b/dashboard/src/components/RepositoryNav.tsx
deleted file mode 100644
index 314a70b7b..000000000
--- a/dashboard/src/components/RepositoryNav.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { A } from '@solidjs/router'
-import { Component } from 'solid-js'
-import { providerToIcon, repositoryURLToProvider } from '../libs/application'
-import { CenterInline } from '../libs/layout'
-import { NavAnchorActiveStyle, NavAnchorStyle, NavButtonsContainer, NavContainer, NavTitleContainer } from './Nav'
-
-export interface RepositoryNavProps {
- repository: Repository
-}
-
-const RepositoryNav: Component = (props) => {
- return (
-
-
- {providerToIcon(repositoryURLToProvider(props.repository.url), 36)}
- {props.repository.name}
-
-
-
- General
-
-
- Settings
-
-
-
- )
-}
-
-export default RepositoryNav
diff --git a/dashboard/src/components/RepositoryRow.tsx b/dashboard/src/components/RepositoryRow.tsx
deleted file mode 100644
index 6de45c68e..000000000
--- a/dashboard/src/components/RepositoryRow.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import AppRow from '/@/components/AppRow'
-import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { A } from '@solidjs/router'
-import { For, JSXElement } from 'solid-js'
-
-const Header = styled('div', {
- base: {
- height: '60px',
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
-
- padding: '0 20px',
- backgroundColor: vars.bg.white3,
-
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- overflow: 'hidden',
-
- selectors: {
- '&:not(:last-child)': {
- borderBottom: 'none',
- borderRadius: '4px 4px 0 0',
- },
- },
- },
-})
-
-const HeaderLeft = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '8px',
- width: '100%',
- },
-})
-
-const RepoName = styled('div', {
- base: {
- fontSize: '16px',
- fontWeight: 500,
- color: vars.text.black1,
- },
-})
-
-const AppsCount = styled('div', {
- base: {
- display: 'flex',
- fontSize: '11px',
- color: vars.text.black3,
- },
-})
-
-const AddBranchButton = styled('div', {
- base: {
- display: 'flex',
- alignItems: 'center',
-
- padding: '8px 16px',
- borderRadius: '4px',
- backgroundColor: vars.bg.white5,
-
- fontSize: '12px',
- color: vars.text.black2,
- },
-})
-
-export type Provider = 'GitHub' | 'GitLab' | 'Gitea'
-
-export interface Props {
- repo: Repository
- apps: Application[]
-}
-
-export const RepositoryRow = (props: Props): JSXElement => {
- const provider = repositoryURLToProvider(props.repo.url)
- return (
-
- )
-}
diff --git a/dashboard/src/components/Select.tsx b/dashboard/src/components/Select.tsx
deleted file mode 100644
index 47305b655..000000000
--- a/dashboard/src/components/Select.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { For } from 'solid-js'
-
-const StyledSelect = styled('select', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '2px',
-
- padding: '8px 12px',
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- fontSize: '14px',
- marginLeft: '4px',
-
- selectors: {
- '&:focus': {
- border: `1px solid ${vars.bg.black1}`,
- },
- },
- },
-})
-
-export interface SelectItem {
- value: T
- title: string
-}
-
-export interface SelectProps {
- items: SelectItem[]
- selected: T
- onSelect: (s: T) => void
-}
-
-export const Select = (props: SelectProps) => {
- return (
- props.onSelect(props.items[e.target.selectedIndex].value)}>
- {(item) => }
-
- )
-}
diff --git a/dashboard/src/components/StatusIcon.tsx b/dashboard/src/components/StatusIcon.tsx
deleted file mode 100644
index 3e398adf0..000000000
--- a/dashboard/src/components/StatusIcon.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ApplicationState } from '/@/libs/application'
-import { vars } from '/@/theme'
-import { AiFillCheckCircle, AiFillExclamationCircle, AiFillMinusCircle } from 'solid-icons/ai'
-import { IoReloadCircle } from 'solid-icons/io'
-import { JSXElement } from 'solid-js'
-import { Dynamic } from 'solid-js/web'
-
-interface IconProps {
- size: number
-}
-const components: Record JSXElement> = {
- Idle: (props) => ,
- Deploying: (props) => ,
- Running: (props) => ,
- Static: (props) => ,
- Error: (props) => ,
-}
-
-interface Props {
- state: ApplicationState
- size?: number
-}
-
-export const StatusIcon = (props: Props): JSXElement => {
- return
-}
diff --git a/dashboard/src/components/UI/Badge.tsx b/dashboard/src/components/UI/Badge.tsx
new file mode 100644
index 000000000..20a17e7ec
--- /dev/null
+++ b/dashboard/src/components/UI/Badge.tsx
@@ -0,0 +1,30 @@
+import { styled } from '@macaron-css/solid'
+import { colorVars, textVars } from '/@/theme'
+
+const Badge = styled('div', {
+ base: {
+ height: '1.43em', // 20px
+ padding: '0 8px',
+ borderRadius: '9999px',
+
+ ...textVars.caption.regular,
+ },
+ variants: {
+ variant: {
+ text: {
+ background: colorVars.primitive.blackAlpha[200],
+ color: colorVars.semantic.text.black,
+ },
+ success: {
+ background: colorVars.semantic.transparent.successHover,
+ color: colorVars.semantic.accent.success,
+ },
+ warn: {
+ background: colorVars.semantic.transparent.warnHover,
+ color: colorVars.semantic.accent.warn,
+ },
+ },
+ },
+})
+
+export default Badge
diff --git a/dashboard/src/components/UI/Button.tsx b/dashboard/src/components/UI/Button.tsx
new file mode 100644
index 000000000..84b6fc29a
--- /dev/null
+++ b/dashboard/src/components/UI/Button.tsx
@@ -0,0 +1,228 @@
+import { styled } from '@macaron-css/solid'
+import { JSX, ParentComponent, Show, splitProps } from 'solid-js'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars, textVars } from '/@/theme'
+import { ToolTip, TooltipProps } from './ToolTip'
+
+const Container = styled('button', {
+ base: {
+ width: 'auto',
+ display: 'flex',
+ alignItems: 'center',
+ borderRadius: '8px',
+ gap: '4px',
+
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ selectors: {
+ '&:disabled': {
+ cursor: 'not-allowed',
+ border: 'none !important',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ '&[data-loading="true"]': {
+ cursor: 'wait',
+ border: 'none !important',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+ },
+ variants: {
+ size: {
+ medium: {
+ height: '44px',
+ padding: '0 16px',
+ },
+ small: {
+ height: '32px',
+ padding: '0 12px',
+ },
+ },
+ full: {
+ true: {
+ width: '100%',
+ },
+ },
+ variants: {
+ primary: {
+ background: colorVars.semantic.primary.main,
+ color: colorVars.semantic.text.white,
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[200]),
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[300]),
+ },
+ },
+ },
+ ghost: {
+ background: colorVars.semantic.ui.secondary,
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.secondary, colorVars.primitive.blackAlpha[50]),
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorOverlay(colorVars.semantic.ui.secondary, colorVars.primitive.blackAlpha[200]),
+ },
+ },
+ },
+ border: {
+ border: `solid 1px ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+ },
+ text: {
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active, &[data-active="true"]': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+ },
+ primaryError: {
+ border: `solid 1px ${colorVars.semantic.accent.error}`,
+ background: colorVars.semantic.accent.error,
+ color: colorVars.semantic.text.white,
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.accent.error, colorVars.primitive.blackAlpha[200]),
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorOverlay(colorVars.semantic.accent.error, colorVars.primitive.blackAlpha[300]),
+ },
+ },
+ },
+ borderError: {
+ border: `solid 1px ${colorVars.semantic.accent.error}`,
+ color: colorVars.semantic.accent.error,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.errorHover,
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorVars.semantic.transparent.errorSelected,
+ },
+ },
+ },
+ textError: {
+ color: colorVars.semantic.accent.error,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.errorHover,
+ },
+ '&:active, &[data-active="true"]': {
+ background: colorVars.semantic.transparent.errorSelected,
+ },
+ },
+ },
+ },
+ hasCheckbox: {
+ true: {
+ gap: '8px',
+ },
+ },
+ },
+})
+const Text = styled('div', {
+ base: {
+ whiteSpace: 'nowrap',
+ },
+ variants: {
+ size: {
+ medium: {
+ ...textVars.text.bold,
+ },
+ small: {
+ ...textVars.caption.bold,
+ },
+ },
+ },
+})
+const IconContainer = styled('div', {
+ base: {
+ lineHeight: 1,
+ },
+ variants: {
+ size: {
+ medium: {
+ width: '24px',
+ height: '24px',
+ },
+ small: {
+ width: '20px',
+ height: '20px',
+ },
+ },
+ },
+})
+
+export interface Props extends JSX.ButtonHTMLAttributes {
+ variants: 'primary' | 'ghost' | 'border' | 'text' | 'primaryError' | 'borderError' | 'textError'
+ size: 'medium' | 'small'
+ loading?: boolean
+ active?: boolean
+ hasCheckbox?: boolean
+ full?: boolean
+ tooltip?: TooltipProps
+ leftIcon?: JSX.Element
+ rightIcon?: JSX.Element
+}
+
+export const Button: ParentComponent = (props) => {
+ const [addedProps, originalButtonProps] = splitProps(props, [
+ 'variants',
+ 'size',
+ 'loading',
+ 'active',
+ 'hasCheckbox',
+ 'full',
+ 'tooltip',
+ 'leftIcon',
+ 'rightIcon',
+ 'children',
+ ])
+
+ return (
+
+
+
+
+ {addedProps.leftIcon}
+
+ {addedProps.children}
+
+ {addedProps.rightIcon}
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/UI/CheckBoxIcon.tsx b/dashboard/src/components/UI/CheckBoxIcon.tsx
new file mode 100644
index 000000000..889d4a085
--- /dev/null
+++ b/dashboard/src/components/UI/CheckBoxIcon.tsx
@@ -0,0 +1,67 @@
+import { styled } from '@macaron-css/solid'
+import { Component, Show } from 'solid-js'
+import CheckMark from '/@/assets/icons/check.svg'
+// import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: 'auto',
+ aspectRatio: '1',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+
+ borderRadius: '4px',
+ color: colorVars.semantic.ui.primary,
+ },
+ variants: {
+ checked: {
+ false: {
+ background: colorVars.semantic.ui.background,
+ border: `2px solid ${colorVars.semantic.ui.tertiary}`,
+ // selectors: {
+ // '&:hover': {
+ // background: colorOverlay(colorVars.semantic.ui.tertiary, colorVars.primitive.blackAlpha[200]),
+ // },
+ // '&:active': {
+ // background: colorOverlay(colorVars.semantic.ui.tertiary, colorVars.primitive.blackAlpha[300]),
+ // },
+ // },
+ },
+ true: {
+ background: colorVars.semantic.primary.main,
+ // selectors: {
+ // '&:hover': {
+ // background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[200]),
+ // },
+ // '&:active': {
+ // background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[300]),
+ // },
+ // },
+ },
+ },
+ disabled: {
+ true: {
+ cursor: 'not-allowed',
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+ },
+})
+
+export interface Props {
+ checked: boolean
+ disabled?: boolean
+}
+
+export const CheckBoxIcon: Component = (props) => {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/UI/Code.tsx b/dashboard/src/components/UI/Code.tsx
new file mode 100644
index 000000000..90442f57c
--- /dev/null
+++ b/dashboard/src/components/UI/Code.tsx
@@ -0,0 +1,87 @@
+import { styled } from '@macaron-css/solid'
+import { Component, Show } from 'solid-js'
+import { writeToClipboard } from '/@/libs/clipboard'
+import { colorVars } from '/@/theme'
+import { MaterialSymbols } from './MaterialSymbols'
+import { ToolTip } from './ToolTip'
+
+const Container = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ minHeight: 'calc(1lh + 8px)',
+ marginTop: '4px',
+ whiteSpace: 'pre-wrap',
+ overflowX: 'auto',
+ padding: '4px 8px',
+ fontSize: '16px',
+ lineHeight: '1.5',
+ fontFamily: 'Menlo, Monaco, Consolas, Courier New, monospace !important',
+ background: colorVars.semantic.ui.secondary,
+ borderRadius: '4px',
+ color: colorVars.semantic.text.black,
+ },
+ variants: {
+ copyable: {
+ true: {
+ paddingRight: '40px',
+ },
+ },
+ },
+})
+const CopyButton = styled('button', {
+ base: {
+ position: 'absolute',
+ width: '24px',
+ height: '24px',
+ top: '4px',
+ right: '8px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: `solid 1px ${colorVars.semantic.ui.border}`,
+ borderRadius: '4px',
+
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ background: 'none',
+ lineHeight: 1,
+
+ selectors: {
+ '&:hover': {
+ background: colorVars.primitive.blackAlpha[200],
+ },
+ '&:active': {
+ background: colorVars.primitive.blackAlpha[300],
+ },
+ },
+ },
+})
+
+const Code: Component<{
+ value: string
+ copyable?: boolean
+}> = (props) => {
+ const handleCopy = () => {
+ writeToClipboard(props.value)
+ }
+
+ return (
+
+ {props.value}
+
+
+
+ content_copy
+
+
+
+
+ )
+}
+
+export default Code
diff --git a/dashboard/src/components/UI/JumpButton.tsx b/dashboard/src/components/UI/JumpButton.tsx
new file mode 100644
index 000000000..38820262d
--- /dev/null
+++ b/dashboard/src/components/UI/JumpButton.tsx
@@ -0,0 +1,46 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { VoidComponent } from 'solid-js'
+import { colorVars } from '/@/theme'
+import { MaterialSymbols } from './MaterialSymbols'
+
+const JumpButtonContainer = styled('div', {
+ base: {
+ width: '32px',
+ height: '32px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+
+ flexShrink: 0,
+ background: 'none',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active, &[data-active="true"]': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ '&:disabled': {
+ cursor: 'not-allowed',
+ border: 'none !important',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+ },
+})
+const JumpButton: VoidComponent<{ href: string }> = (props) => (
+
+
+ arrow_outward
+
+
+)
+
+export default JumpButton
diff --git a/dashboard/src/components/Log.tsx b/dashboard/src/components/UI/LogContainer.tsx
similarity index 74%
rename from dashboard/src/components/Log.tsx
rename to dashboard/src/components/UI/LogContainer.tsx
index 5efc34736..d8fd77210 100644
--- a/dashboard/src/components/Log.tsx
+++ b/dashboard/src/components/UI/LogContainer.tsx
@@ -1,16 +1,16 @@
-import { vars } from '/@/theme'
import { styled } from '@macaron-css/solid'
+import { colorVars } from '/@/theme'
export const LogContainer = styled('code', {
base: {
display: 'flex',
flexDirection: 'column',
fontSize: '15px',
- lineHeight: '20px',
+ lineHeight: '150%',
- backgroundColor: vars.bg.black1,
- padding: '10px',
- color: vars.text.white1,
+ backgroundColor: colorVars.primitive.gray[800],
+ padding: '4px 8px',
+ color: colorVars.semantic.text.white,
borderRadius: '4px',
maxHeight: '500px',
diff --git a/dashboard/src/components/UI/MaterialSymbols.tsx b/dashboard/src/components/UI/MaterialSymbols.tsx
new file mode 100644
index 000000000..833a73ce2
--- /dev/null
+++ b/dashboard/src/components/UI/MaterialSymbols.tsx
@@ -0,0 +1,71 @@
+import { style } from '@macaron-css/core'
+import { JSX, ParentComponent, mergeProps, splitProps } from 'solid-js'
+
+// see https://developers.google.com/fonts/docs/material_symbols?hl=ja#self-hosting_the_font
+const baseStyle = style({
+ fontFamily: 'Material Symbols Rounded',
+ fontWeight: 'normal',
+ fontStyle: 'normal',
+ display: 'inline-block',
+ lineHeight: 1,
+ textTransform: 'none',
+ letterSpacing: 'normal',
+ wordWrap: 'normal',
+ whiteSpace: 'nowrap',
+ direction: 'ltr',
+ flexShrink: 0,
+ overflow: 'hidden',
+})
+
+export interface Props extends JSX.HTMLAttributes {
+ fill?: boolean
+ weight?: 300
+ grade?: 0
+ opticalSize?: 20 | 24
+ displaySize?: number
+ color?: string
+}
+
+export const MaterialSymbols: ParentComponent = (props) => {
+ const [addedProps, originalProps] = splitProps(props, [
+ 'children',
+ 'fill',
+ 'weight',
+ 'grade',
+ 'opticalSize',
+ 'displaySize',
+ 'color',
+ ])
+ const mergedProps = mergeProps(
+ {
+ type: 'rounded',
+ fill: false,
+ weight: 300,
+ grade: 0,
+ opticalSize: 24,
+ color: 'currentColor',
+ },
+ addedProps,
+ )
+ const size = () => (mergedProps.displaySize ? `${mergedProps.displaySize}px` : `${mergedProps.opticalSize}px`)
+
+ return (
+
+ {mergedProps.children}
+
+ )
+}
diff --git a/dashboard/src/components/UI/ModalDeleteConfirm.tsx b/dashboard/src/components/UI/ModalDeleteConfirm.tsx
new file mode 100644
index 000000000..6d07046e7
--- /dev/null
+++ b/dashboard/src/components/UI/ModalDeleteConfirm.tsx
@@ -0,0 +1,24 @@
+import { styled } from '@macaron-css/solid'
+import { ParentComponent } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+
+const DeleteConfirm = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+ borderRadius: '8px',
+ background: colorVars.semantic.ui.secondary,
+ overflowWrap: 'anywhere',
+ color: colorVars.semantic.text.black,
+ ...textVars.h3.regular,
+ },
+})
+const ModalDeleteConfirm: ParentComponent = (props) => {
+ return {props.children}
+}
+
+export default ModalDeleteConfirm
diff --git a/dashboard/src/components/UI/RadioIcon.tsx b/dashboard/src/components/UI/RadioIcon.tsx
new file mode 100644
index 000000000..1b2738c63
--- /dev/null
+++ b/dashboard/src/components/UI/RadioIcon.tsx
@@ -0,0 +1,79 @@
+import { styled } from '@macaron-css/solid'
+import { Component, Show } from 'solid-js'
+// import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '20px',
+ height: '20px',
+
+ borderRadius: '9999px',
+ color: colorVars.semantic.ui.primary,
+ },
+ variants: {
+ checked: {
+ false: {
+ background: colorVars.semantic.ui.background,
+ border: `2px solid ${colorVars.semantic.ui.tertiary}`,
+ // selectors: {
+ // '&:hover': {
+ // background: colorOverlay(colorVars.semantic.ui.tertiary, colorVars.primitive.blackAlpha[200]),
+ // },
+ // '&:active': {
+ // background: colorOverlay(colorVars.semantic.ui.tertiary, colorVars.primitive.blackAlpha[300]),
+ // },
+ // },
+ },
+ true: {
+ background: colorVars.semantic.primary.main,
+ selectors: {
+ '&::after': {
+ // white circle in the middle
+ content: '""',
+ width: '8px',
+ height: '8px',
+ borderRadius: '9999px',
+ background: colorVars.semantic.ui.primary,
+ },
+ // '&:hover': {
+ // background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[200]),
+ // },
+ // '&:active': {
+ // background: colorOverlay(colorVars.semantic.primary.main, colorVars.primitive.blackAlpha[300]),
+ // },
+ },
+ },
+ },
+ disabled: {
+ true: {
+ cursor: 'not-allowed',
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+ },
+})
+
+export interface Props {
+ selected: boolean
+ disabled?: boolean
+}
+
+export const RadioIcon: Component = (props) => {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/UI/Skeleton.tsx b/dashboard/src/components/UI/Skeleton.tsx
new file mode 100644
index 000000000..759ab3aaf
--- /dev/null
+++ b/dashboard/src/components/UI/Skeleton.tsx
@@ -0,0 +1,59 @@
+import { Skeleton as KSkeleton } from '@kobalte/core'
+import { SkeletonProps } from '@kobalte/core/dist/types/skeleton/skeleton'
+import { keyframes, style } from '@macaron-css/core'
+import { Component, mergeProps } from 'solid-js'
+
+const skeletonAnimation = keyframes({
+ from: {
+ transform: 'translateX(-100%)',
+ },
+ to: {
+ transform: 'translateX(100%)',
+ },
+})
+const skeletonClass = style({
+ position: 'relative',
+ width: 'auto',
+ height: 'auto',
+ flexShrink: '0',
+ opacity: '0.2',
+
+ selectors: {
+ "&[data-visible='true']": {
+ overflow: 'hidden',
+ },
+ "&[data-visible='true']::after": {
+ position: 'absolute',
+ content: '""',
+ inset: '0',
+ backgroundColor: 'currentcolor',
+ backgroundImage: 'linear-gradient(90deg, transparent, #fff4, transparent)',
+ },
+ "&[data-visible='true']::before": {
+ position: 'absolute',
+ content: '""',
+ inset: '0',
+ backgroundColor: 'currentcolor',
+ },
+ "&[data-animate='true']::after": {
+ animation: `${skeletonAnimation} 1.5s linear infinite`,
+ },
+ },
+})
+
+const defaultProps: SkeletonProps = {
+ radius: 999,
+ width: -1, // for `width: auto`
+}
+
+const Skeleton: Component = (props) => {
+ const mergedProps = mergeProps(defaultProps, props)
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+export default Skeleton
diff --git a/dashboard/src/components/UI/StepProgress.tsx b/dashboard/src/components/UI/StepProgress.tsx
new file mode 100644
index 000000000..d2a9cdc52
--- /dev/null
+++ b/dashboard/src/components/UI/StepProgress.tsx
@@ -0,0 +1,98 @@
+import { styled } from '@macaron-css/solid'
+import { Component } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+
+const Steps = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ gap: '16px',
+ },
+})
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ },
+})
+const Bar = styled('div', {
+ base: {
+ width: '100%',
+ height: '4px',
+ borderRadius: '2px',
+ },
+ variants: {
+ state: {
+ complete: {
+ background: colorVars.semantic.primary.main,
+ },
+ current: {
+ background: colorVars.semantic.primary.main,
+ },
+ incomplete: {
+ background: colorVars.semantic.ui.tertiary,
+ },
+ },
+ },
+})
+const Content = styled('div', {
+ base: {
+ width: '100%',
+ padding: '6px 12px 8px 12px',
+ display: 'flex',
+ flexDirection: 'column',
+ borderRadius: '4px',
+ },
+ variants: {
+ state: {
+ complete: {
+ color: colorVars.semantic.primary.main,
+ },
+ current: {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ incomplete: {
+ color: colorVars.semantic.text.grey,
+ },
+ },
+ },
+})
+const Title = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '6px',
+ ...textVars.h3.bold,
+ },
+})
+const Description = styled('div', {
+ base: {
+ ...textVars.caption.regular,
+ },
+})
+
+const StepProgress: Component<{
+ title: string
+ description: string
+ state: 'complete' | 'current' | 'incomplete'
+}> = (props) => {
+ return (
+
+
+
+ {props.title}
+ {props.description}
+
+
+ )
+}
+
+export const Progress = {
+ Container: Steps,
+ Step: StepProgress,
+}
diff --git a/dashboard/src/components/UI/TabRound.tsx b/dashboard/src/components/UI/TabRound.tsx
new file mode 100644
index 000000000..515dacace
--- /dev/null
+++ b/dashboard/src/components/UI/TabRound.tsx
@@ -0,0 +1,72 @@
+import { styled } from '@macaron-css/solid'
+import { JSX, splitProps } from 'solid-js'
+import { ParentComponent } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+
+const Container = styled('button', {
+ base: {
+ width: 'fit-content',
+ height: '44px',
+ padding: '0 16px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ border: 'none',
+ borderRadius: '9999px',
+ ...textVars.h4.medium,
+ whiteSpace: 'nowrap',
+ cursor: 'pointer',
+ },
+ variants: {
+ state: {
+ active: {
+ background: colorVars.semantic.transparent.primaryHover,
+ color: `${colorVars.semantic.primary.main} !important`,
+ boxShadow: `inset 0 0 0 2px ${colorVars.semantic.primary.main} !important`,
+ },
+ default: {
+ background: 'none',
+ color: colorVars.semantic.text.grey,
+ boxShadow: `inset 0 0 0 1px ${colorVars.semantic.ui.border}`,
+ },
+ },
+ variant: {
+ primary: {
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ color: colorVars.semantic.text.grey,
+ boxShadow: `inset 0 0 0 1px ${colorVars.semantic.ui.border}`,
+ },
+ },
+ },
+ ghost: {
+ background: colorVars.primitive.blackAlpha[50],
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.primitive.blackAlpha[200],
+ },
+ },
+ },
+ },
+ },
+ defaultVariants: {
+ variant: 'primary',
+ },
+})
+
+export interface Props extends JSX.ButtonHTMLAttributes {
+ state?: 'active' | 'default'
+ variant?: 'primary' | 'ghost'
+}
+
+export const TabRound: ParentComponent = (props) => {
+ const [addedProps, originalButtonProps] = splitProps(props, ['state', 'variant', 'children'])
+
+ return (
+
+ {addedProps.children}
+
+ )
+}
diff --git a/dashboard/src/components/UI/TextField.tsx b/dashboard/src/components/UI/TextField.tsx
new file mode 100644
index 000000000..a1b4a53b3
--- /dev/null
+++ b/dashboard/src/components/UI/TextField.tsx
@@ -0,0 +1,213 @@
+import { TextField as KTextField } from '@kobalte/core'
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Component, JSX, Show, splitProps } from 'solid-js'
+import { writeToClipboard } from '/@/libs/clipboard'
+import { colorVars, textVars } from '/@/theme'
+import { RequiredMark, TitleContainer, containerStyle, errorTextStyle, titleStyle } from '../templates/FormItem'
+import { MaterialSymbols } from './MaterialSymbols'
+import { ToolTip, TooltipProps } from './ToolTip'
+import { TooltipInfoIcon } from './TooltipInfoIcon'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '4px',
+ },
+})
+export const ActionsContainer = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ display: 'flex',
+ gap: '1px',
+ },
+})
+const inputStyle = style({
+ width: '100%',
+ height: '100%',
+ padding: '0',
+ border: 'none',
+
+ selectors: {
+ '&::placeholder': {
+ color: colorVars.semantic.text.disabled,
+ },
+ '&:focus-visible': {
+ outline: 'none',
+ },
+ },
+})
+const InputContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '48px',
+ padding: '0 16px',
+ display: 'flex',
+ gap: '4px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: 'none',
+ outline: `1px solid ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+
+ selectors: {
+ '&:focus-within': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ [`&:has(${inputStyle}[data-disabled])`]: {
+ cursor: 'not-allowed',
+ background: colorVars.semantic.ui.tertiary,
+ },
+ [`&:has(${inputStyle}[data-invalid])`]: {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+ },
+ variants: {
+ copyable: {
+ true: {
+ borderRadius: '8px 0 0 8px',
+ },
+ },
+ },
+})
+const Icon = styled('div', {
+ base: {
+ color: colorVars.semantic.text.disabled,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+})
+const CopyButton = styled('button', {
+ base: {
+ width: '48px',
+ flexShrink: 0,
+ borderRadius: '0 8px 8px 0',
+ border: 'none',
+ outline: `1px solid ${colorVars.semantic.ui.border}`,
+
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ background: colorVars.primitive.blackAlpha[100],
+ lineHeight: 1,
+
+ selectors: {
+ '&:hover': {
+ background: colorVars.primitive.blackAlpha[200],
+ },
+ '&:active': {
+ background: colorVars.primitive.blackAlpha[300],
+ },
+ },
+ },
+})
+const textareaStyle = style({
+ width: '100%',
+ height: '100%',
+ padding: '10px 16px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: 'none',
+ outline: `1px solid ${colorVars.semantic.ui.border}`,
+ wordBreak: 'break-all',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+ resize: 'none',
+
+ selectors: {
+ '&::placeholder': {
+ color: colorVars.semantic.text.disabled,
+ },
+ '&:focus-visible': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ background: colorVars.semantic.ui.tertiary,
+ },
+ '&[data-invalid]': {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+})
+
+export interface Props extends JSX.InputHTMLAttributes {
+ value?: string
+ error?: string
+ label?: string
+ leftIcon?: JSX.Element
+ rightIcon?: JSX.Element
+ multiline?: boolean
+ info?: TooltipProps
+ tooltip?: TooltipProps
+ copyable?: boolean
+ ref?: (element: HTMLInputElement | HTMLTextAreaElement) => void
+ onInput?: JSX.EventHandler
+ onChange?: JSX.EventHandler
+ onBlur?: JSX.EventHandler
+}
+
+export const TextField: Component = (props) => {
+ const [rootProps, _addedProps, inputProps] = splitProps(
+ props,
+ ['name', 'value', 'required', 'disabled', 'readOnly'],
+ ['label', 'leftIcon', 'rightIcon', 'info', 'tooltip', 'copyable'],
+ )
+
+ const handleCopy = () => {
+ if (rootProps.value) {
+ writeToClipboard(rootProps.value.toString())
+ }
+ }
+
+ return (
+
+
+
+ {props.label}
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+ {props.leftIcon}
+
+
+
+ {props.rightIcon}
+
+
+
+
+ content_copy
+
+
+
+
+
+ }
+ >
+
+
+ {props.error}
+
+ )
+}
diff --git a/dashboard/src/components/UI/ToolTip.tsx b/dashboard/src/components/UI/ToolTip.tsx
new file mode 100644
index 000000000..dd431f4ee
--- /dev/null
+++ b/dashboard/src/components/UI/ToolTip.tsx
@@ -0,0 +1,69 @@
+import { styled } from '@macaron-css/solid'
+import { FlowComponent, JSX, children, mergeProps, onMount, splitProps } from 'solid-js'
+import { TippyOptions, tippy } from 'solid-tippy'
+import { Props } from 'tippy.js'
+import 'tippy.js/animations/shift-away-subtle.css'
+import 'tippy.js/dist/tippy.css'
+
+const TooltipContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ variants: {
+ align: {
+ left: {
+ alignItems: 'flex-start',
+ },
+ center: {
+ alignItems: 'center',
+ },
+ },
+ },
+})
+
+export type TooltipProps = Omit & {
+ props?: Partial<
+ Omit & {
+ content?: JSX.Element
+ }
+ >
+} & {
+ /**
+ * @default "center"
+ */
+ style?: 'left' | 'center'
+}
+
+export const ToolTip: FlowComponent = (props) => {
+ const defaultOptions: TooltipProps = {
+ style: 'center',
+ hidden: true,
+ props: {
+ allowHTML: true,
+ maxWidth: 1000,
+ animation: 'shift-away-subtle',
+ },
+ disabled: props.props?.content === undefined,
+ }
+ const propsWithDefaults = mergeProps(defaultOptions, props)
+ const [addedProps, tippyProps] = splitProps(propsWithDefaults, ['style', 'children'])
+ const c = children(() => props.children)
+
+ onMount(() => {
+ for (const child of c.toArray()) {
+ if (child instanceof Element) {
+ tippy(child, () => ({
+ ...tippyProps,
+ props: {
+ content: (
+ {propsWithDefaults.props?.content}
+ ) as Element,
+ },
+ }))
+ }
+ }
+ })
+
+ return <>{c()}>
+}
diff --git a/dashboard/src/components/UI/TooltipInfoIcon.tsx b/dashboard/src/components/UI/TooltipInfoIcon.tsx
new file mode 100644
index 000000000..ca3d94b7c
--- /dev/null
+++ b/dashboard/src/components/UI/TooltipInfoIcon.tsx
@@ -0,0 +1,14 @@
+import { Component } from 'solid-js'
+import { colorVars } from '/@/theme'
+import { MaterialSymbols } from './MaterialSymbols'
+import { ToolTip, TooltipProps } from './ToolTip'
+
+export const TooltipInfoIcon: Component = (props) => {
+ return (
+
+
+ help
+
+
+ )
+}
diff --git a/dashboard/src/components/URLText.tsx b/dashboard/src/components/UI/URLText.tsx
similarity index 59%
rename from dashboard/src/components/URLText.tsx
rename to dashboard/src/components/UI/URLText.tsx
index 7da0c10cb..6057ad136 100644
--- a/dashboard/src/components/URLText.tsx
+++ b/dashboard/src/components/UI/URLText.tsx
@@ -1,18 +1,15 @@
-import { vars } from '/@/theme'
import { styled } from '@macaron-css/solid'
-import { VsLinkExternal } from 'solid-icons/vs'
import { Component } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
+import { colorVars, textVars } from '/@/theme'
+import { MaterialSymbols } from './MaterialSymbols'
+import { ToolTip } from './ToolTip'
const StyledAnchor = styled('a', {
base: {
- color: vars.text.url,
+ color: colorVars.semantic.text.link,
+ ...textVars.text.regular,
},
})
-
const ContentContainer = styled('div', {
base: {
display: 'flex',
@@ -29,19 +26,18 @@ export interface URLTextProps {
export const URLText: Component = (props) => {
return (
-
{props.text}
-
+ open_in_new
-
+
)
}
diff --git a/dashboard/src/components/UserAvatar.tsx b/dashboard/src/components/UI/UserAvater.tsx
similarity index 95%
rename from dashboard/src/components/UserAvatar.tsx
rename to dashboard/src/components/UI/UserAvater.tsx
index fe7b5dc63..467909b40 100644
--- a/dashboard/src/components/UserAvatar.tsx
+++ b/dashboard/src/components/UI/UserAvater.tsx
@@ -1,6 +1,6 @@
-import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
import { styled } from '@macaron-css/solid'
import { Component, JSX, splitProps } from 'solid-js'
+import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
const UserAvatarImg = styled('img', {
base: {
@@ -23,6 +23,7 @@ const UserAvatar: Component = (props) => {
style={{
width: addedProps.size ? `${addedProps.size}px` : '100%',
}}
+ alt={addedProps.user.name}
{...originalImgProps}
/>
)
diff --git a/dashboard/src/components/UI/UserMenuButton.tsx b/dashboard/src/components/UI/UserMenuButton.tsx
new file mode 100644
index 000000000..4bd3cb594
--- /dev/null
+++ b/dashboard/src/components/UI/UserMenuButton.tsx
@@ -0,0 +1,139 @@
+import { DropdownMenu } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component } from 'solid-js'
+import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { colorVars, media, textVars } from '/@/theme'
+import { Button } from './Button'
+import { MaterialSymbols } from './MaterialSymbols'
+import UserAvatar from './UserAvater'
+
+const triggerStyle = style({
+ position: 'relative',
+ width: 'fit-content',
+ height: '44px',
+ padding: '0 8px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ cursor: 'pointer',
+
+ border: 'none',
+ borderRadius: '8px',
+ background: 'none',
+
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active': {
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+})
+const UserName = styled('span', {
+ base: {
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+
+ '@media': {
+ [media.mobile]: {
+ display: 'none',
+ },
+ },
+ },
+})
+const iconStyle = style({
+ width: '24px',
+ height: '24px',
+ transition: 'transform 0.2s',
+ selectors: {
+ '&[data-expanded]': {
+ transform: 'rotate(180deg)',
+ },
+ },
+})
+const contentShowKeyframes = keyframes({
+ from: { opacity: 0, transform: 'translateY(-8px)' },
+ to: { opacity: 1, transform: 'translateY(0)' },
+})
+const contentHideKeyframes = keyframes({
+ from: { opacity: 1, transform: 'translateY(0)' },
+ to: { opacity: 0, transform: 'translateY(-8px)' },
+})
+const contentStyle = style({
+ padding: '6px',
+ display: 'flex',
+ flexDirection: 'column',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '6px',
+ boxShadow: '0px 0px 20px 0px rgba(0, 0, 0, 0.10)',
+ zIndex: 1,
+
+ transformOrigin: 'var(--kb-menu-content-transform-origin)',
+ animation: `${contentHideKeyframes} 0.2s ease-in-out`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${contentShowKeyframes} 0.2s ease-in-out`,
+ },
+ },
+})
+
+export const UserMenuButton: Component<{
+ user: User
+}> = (props) => {
+ return (
+
+
+
+ {props.user.name}
+
+ arrow_drop_down
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ // setShowOptions((s) => !s)}>
+ //
+ // {props.user.name}
+ // arrow_drop_down
+ //
+ //
+ //
+ //
+}
diff --git a/dashboard/src/components/UserSearch.tsx b/dashboard/src/components/UserSearch.tsx
deleted file mode 100644
index 5c8029f85..000000000
--- a/dashboard/src/components/UserSearch.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { InputBar } from '/@/components/Input'
-import UserAvatar from '/@/components/UserAvatar'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import Fuse from 'fuse.js'
-import { FlowComponent, For, JSX, Show, createMemo, createSignal } from 'solid-js'
-
-const UserSearchContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '16px',
- },
-})
-const UserRow = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- gap: '16px',
- },
-})
-const UserContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- gap: '8px',
- alignItems: 'center',
- },
-})
-const UserName = styled('div', {
- base: {
- fontSize: '16px',
- color: vars.text.black1,
- },
-})
-const UsersList = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '8px',
- overflowY: 'auto',
- maxHeight: '400px',
- },
-})
-const NoUsersFound = styled('div', {
- base: {
- color: vars.text.black2,
- textAlign: 'center',
- },
-})
-
-export const UserSearch: FlowComponent<
- {
- users: User[]
- },
- (user: User) => JSX.Element
-> = (props) => {
- const [userSearchQuery, setUserSearchQuery] = createSignal('')
-
- // users()の更新時にFuseインスタンスを再生成する
- const fuse = createMemo(
- () =>
- new Fuse(props.users, {
- keys: ['name'],
- }),
- )
- // userSearchQuery()の更新時に検索を実行する
- const userSearchResults = createMemo(() => {
- // 検索クエリが空の場合は全ユーザーを表示する
- if (userSearchQuery() === '') {
- return props.users
- } else {
- return fuse()
- .search(userSearchQuery())
- .map((result) => result.item)
- }
- })
-
- return (
-
- setUserSearchQuery(e.target.value)}
- placeholder="search users..."
- />
-
-
- {(user) => (
-
-
-
- {user.name}
-
- {props.children(user)}
-
- )}
-
-
- No Users Found
-
-
-
- )
-}
diff --git a/dashboard/src/components/WebsiteSettings.tsx b/dashboard/src/components/WebsiteSettings.tsx
deleted file mode 100644
index 4a2a5843b..000000000
--- a/dashboard/src/components/WebsiteSettings.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import { AuthenticationType, CreateWebsiteRequest } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { FormButton, FormCheckBox, FormSettings, FormSettingsButton, SettingsContainer } from '/@/components/AppsNew'
-import { Button } from '/@/components/Button'
-import { Checkbox } from '/@/components/Checkbox'
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { InputBar, InputLabel } from '/@/components/Input'
-import { Select, SelectItem } from '/@/components/Select'
-import { PlainMessage } from '@bufbuild/protobuf'
-import { styled } from '@macaron-css/solid'
-import { AiOutlinePlusCircle } from 'solid-icons/ai'
-import { FaRegularTrashCan } from 'solid-icons/fa'
-import { For, Show } from 'solid-js'
-import { SetStoreFunction } from 'solid-js/store'
-import { systemInfo } from '../libs/api'
-import { vars } from '../theme'
-
-const AvailableDomainContainer = styled('div', {
- base: {
- fontSize: '14px',
- color: vars.text.black2,
- padding: '8px',
- },
-})
-
-const AvailableDomainUl = styled('ul', {
- base: {
- margin: '8px 0',
- },
-})
-
-const URLContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- gap: '2px',
- },
-})
-
-const URLWarning = styled('div', {
- base: {
- color: vars.icon.error,
- fontWeight: 700,
- marginTop: '4px',
- },
-})
-
-interface WebsiteSettingProps {
- runtime: boolean
- website: PlainMessage
- setWebsite: >(
- valueName: T,
- value: PlainMessage[T],
- ) => void
- deleteWebsite: () => void
-}
-
-const schemeOptions: SelectItem<'http' | 'https'>[] = [
- { value: 'http', title: 'http' },
- { value: 'https', title: 'https' },
-]
-
-const authenticationTypeItems: SelectItem[] = [
- { value: AuthenticationType.OFF, title: 'OFF' },
- { value: AuthenticationType.SOFT, title: 'SOFT' },
- { value: AuthenticationType.HARD, title: 'HARD' },
-]
-
-export const WebsiteSetting = (props: WebsiteSettingProps) => {
- return (
-
-
- URL
-
-
-
- _ が含まれるホスト名は非推奨です
-
-
-
-
- 部員認証
-
-
-
-
- Advanced
-
- props.setWebsite('stripPrefix', selected)}
- >
- Strip Path Prefix
-
-
-
- props.setWebsite('h2c', selected)}>
- h2c
-
-
-
-
-
-
-
-
-
- )
-}
-
-const newWebsite = (): PlainMessage => ({
- fqdn: '',
- pathPrefix: '/',
- stripPrefix: false,
- https: true,
- h2c: false,
- httpPort: 0,
- authentication: AuthenticationType.OFF,
-})
-
-interface WebsiteSettingsProps {
- runtime: boolean
- websiteConfigs: PlainMessage[]
- setWebsiteConfigs: SetStoreFunction[]>
-}
-
-export const WebsiteSettings = (props: WebsiteSettingsProps) => {
- return (
-
-
- 使用可能なホスト
-
- !ad.alreadyBound) || []}>
- {(domain) => (
-
- {domain.domain}
- 0}> ({domain.excludeDomains.join(', ')}を除く)
- :{domain.authAvailable ? '部員認証の使用可能' : '部員認証の使用不可'}
-
- )}
-
-
-
-
- {(website, i) => (
- {
- props.setWebsiteConfigs(i(), valueName, value)
- }}
- deleteWebsite={() =>
- props.setWebsiteConfigs((current) => [...current.slice(0, i()), ...current.slice(i() + 1)])
- }
- />
- )}
-
-
-
-
-
-
- )
-}
diff --git a/dashboard/src/components/layouts/DataTable.tsx b/dashboard/src/components/layouts/DataTable.tsx
new file mode 100644
index 000000000..273bcfda7
--- /dev/null
+++ b/dashboard/src/components/layouts/DataTable.tsx
@@ -0,0 +1,42 @@
+import { styled } from '@macaron-css/solid'
+import { colorVars, textVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+ },
+})
+const Title = styled('h2', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ color: colorVars.semantic.text.black,
+ ...textVars.h2.medium,
+ },
+})
+const SubTitle = styled('div', {
+ base: {
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.medium,
+ },
+})
+const Titles = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+})
+
+export const DataTable = {
+ Container,
+ Titles,
+ Title,
+ SubTitle,
+}
diff --git a/dashboard/src/components/layouts/ErrorView.tsx b/dashboard/src/components/layouts/ErrorView.tsx
new file mode 100644
index 000000000..8c2c21797
--- /dev/null
+++ b/dashboard/src/components/layouts/ErrorView.tsx
@@ -0,0 +1,74 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+import { Button } from '../UI/Button'
+import { MaterialSymbols } from '../UI/MaterialSymbols'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '1rem',
+ },
+})
+const Title = styled('h2', {
+ base: {
+ color: colorVars.semantic.accent.error,
+ ...textVars.h2.bold,
+ },
+})
+const Message = styled('p', {
+ base: {
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.medium,
+ },
+})
+const ButtonsContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ gap: '8px',
+ },
+})
+
+const ErrorView: Component<{
+ error: unknown
+}> = (props) => {
+ const handleReload = () => {
+ window.location.reload()
+ }
+
+ return (
+
+
+ error
+
+ An error has occurred
+
+ {(props.error as Error).message}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ErrorView
diff --git a/dashboard/src/components/layouts/FormBox.tsx b/dashboard/src/components/layouts/FormBox.tsx
new file mode 100644
index 000000000..bfc8bec63
--- /dev/null
+++ b/dashboard/src/components/layouts/FormBox.tsx
@@ -0,0 +1,42 @@
+import { styled } from '@macaron-css/solid'
+import { colorVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ borderRadius: '8px',
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ background: colorVars.semantic.ui.primary,
+ },
+})
+const Forms = styled('div', {
+ base: {
+ width: '100%',
+ padding: '20px 24px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+ },
+})
+const Actions = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 24px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ gap: '8px',
+ background: colorVars.semantic.ui.secondary,
+ borderTop: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '0 0 8px 8px',
+ },
+})
+
+const FormBox = {
+ Container,
+ Forms,
+ Actions,
+}
+
+export default FormBox
diff --git a/dashboard/src/components/layouts/MainView.tsx b/dashboard/src/components/layouts/MainView.tsx
new file mode 100644
index 000000000..ac9849dec
--- /dev/null
+++ b/dashboard/src/components/layouts/MainView.tsx
@@ -0,0 +1,41 @@
+import { styled } from '@macaron-css/solid'
+import { colorVars, media } from '/@/theme'
+
+export const MainViewContainer = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ padding: '40px max(calc(50% - 500px), 32px) 72px',
+
+ '@media': {
+ [media.mobile]: {
+ padding: '40px 16px 72px',
+ },
+ },
+ },
+ variants: {
+ background: {
+ grey: {
+ background: colorVars.semantic.ui.background,
+ },
+ white: {
+ background: colorVars.semantic.ui.primary,
+ },
+ },
+ scrollable: {
+ true: {
+ overflowY: 'auto',
+ scrollbarGutter: 'stable',
+ },
+ false: {
+ overflowY: 'hidden',
+ scrollbarGutter: 'none',
+ },
+ },
+ },
+ defaultVariants: {
+ background: 'white',
+ scrollable: true,
+ },
+})
diff --git a/dashboard/src/components/layouts/SideView.tsx b/dashboard/src/components/layouts/SideView.tsx
new file mode 100644
index 000000000..b0d2b5e70
--- /dev/null
+++ b/dashboard/src/components/layouts/SideView.tsx
@@ -0,0 +1,36 @@
+import { styled } from '@macaron-css/solid'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ display: 'grid',
+ gridTemplateColumns: '235px minmax(0, 1fr)',
+ gap: '48px',
+
+ '@media': {
+ 'screen and (max-width: 1024px)': {
+ gridTemplateColumns: '1fr',
+ gridTemplateRows: 'auto auto',
+ gap: '24px',
+ },
+ },
+ },
+})
+const Side = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ },
+})
+const Main = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ },
+})
+
+export const SideView = {
+ Container,
+ Side,
+ Main,
+}
diff --git a/dashboard/src/components/layouts/SuspenseContainer.tsx b/dashboard/src/components/layouts/SuspenseContainer.tsx
new file mode 100644
index 000000000..6f909690a
--- /dev/null
+++ b/dashboard/src/components/layouts/SuspenseContainer.tsx
@@ -0,0 +1,21 @@
+import { styled } from '@macaron-css/solid'
+
+const SuspenseContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+
+ opacity: 1,
+ transition: 'opacity 0.2s ease-in-out',
+ },
+ variants: {
+ isPending: {
+ true: {
+ opacity: 0.5,
+ pointerEvents: 'none',
+ },
+ },
+ },
+})
+
+export default SuspenseContainer
diff --git a/dashboard/src/components/layouts/WithHeader.tsx b/dashboard/src/components/layouts/WithHeader.tsx
new file mode 100644
index 000000000..9bbc5e51a
--- /dev/null
+++ b/dashboard/src/components/layouts/WithHeader.tsx
@@ -0,0 +1,29 @@
+import { styled } from '@macaron-css/solid'
+import { ParentComponent } from 'solid-js'
+import { Header } from '../templates/Header'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ display: 'grid',
+ gridTemplateColumns: '1fr',
+ gridTemplateRows: 'auto 1fr',
+ },
+})
+const Body = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ overflowY: 'hidden',
+ },
+})
+
+export const WithHeader: ParentComponent = (props) => {
+ return (
+
+
+ {props.children}
+
+ )
+}
diff --git a/dashboard/src/components/layouts/WithNav.tsx b/dashboard/src/components/layouts/WithNav.tsx
new file mode 100644
index 000000000..27fdd38bd
--- /dev/null
+++ b/dashboard/src/components/layouts/WithNav.tsx
@@ -0,0 +1,52 @@
+import { styled } from '@macaron-css/solid'
+import { colorVars, media } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ display: 'grid',
+ gridTemplateColumns: '1fr',
+ gridTemplateRows: 'auto 1fr',
+ },
+})
+const Navs = styled('div', {
+ base: {
+ width: '100%',
+ height: 'auto',
+ overflowX: 'hidden',
+ borderBottom: `1px solid ${colorVars.semantic.ui.border}`,
+ },
+})
+const Body = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ overflowY: 'hidden',
+ },
+})
+const TabContainer = styled('div', {
+ base: {
+ width: '100%',
+ maxWidth: 'min(1000px, calc(100% - 64px))',
+ margin: '0 auto',
+ display: 'flex',
+ gap: '8px',
+ padding: '0 0 16px 0',
+ overflowX: 'auto',
+
+ '@media': {
+ [media.mobile]: {
+ maxWidth: 'min(1000px, calc(100% - 32px))',
+ },
+ },
+ },
+})
+
+export const WithNav = {
+ Container,
+ Navs,
+ Tabs: TabContainer,
+ Body,
+}
diff --git a/dashboard/src/components/templates/AppsNav.tsx b/dashboard/src/components/templates/AppsNav.tsx
new file mode 100644
index 000000000..6f18e4eb8
--- /dev/null
+++ b/dashboard/src/components/templates/AppsNav.tsx
@@ -0,0 +1,6 @@
+import { Component } from 'solid-js'
+import { Nav } from './Nav'
+
+export const AppsNav: Component = () => {
+ return
+}
diff --git a/dashboard/src/components/templates/CheckBox.tsx b/dashboard/src/components/templates/CheckBox.tsx
new file mode 100644
index 000000000..d58c4256d
--- /dev/null
+++ b/dashboard/src/components/templates/CheckBox.tsx
@@ -0,0 +1,108 @@
+import { Checkbox as KCheckbox } from '@kobalte/core'
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Component, JSX, splitProps } from 'solid-js'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars, textVars } from '/@/theme'
+import { CheckBoxIcon } from '../UI/CheckBoxIcon'
+import { ToolTip, TooltipProps } from '../UI/ToolTip'
+
+const Container = styled('div', {
+ base: {
+ width: 'auto',
+ maxWidth: '100%',
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fill, 200px)',
+ gap: '16px',
+ },
+})
+
+const labelStyle = style({
+ width: 'fit-content',
+ minWidth: 'min(200px, 100%)',
+ height: 'auto',
+ padding: '16px',
+ display: 'grid',
+ gridTemplateColumns: '1fr 24px',
+ alignItems: 'center',
+ justifyItems: 'start',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+ cursor: 'pointer',
+
+ selectors: {
+ '&:hover:not([data-disabled]):not([data-readonly])': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ '&[data-readonly]': {
+ cursor: 'not-allowed',
+ },
+ '&[data-checked]': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: colorVars.semantic.text.disabled,
+ background: colorVars.semantic.ui.tertiary,
+ },
+ '&[data-invalid]': {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+})
+const iconStyle = style({
+ width: '24px',
+ height: '24px',
+ flexShrink: 0,
+})
+
+export interface Props {
+ checked?: boolean
+ error?: string
+ label: string
+ name?: string
+ value?: string
+ required?: boolean
+ disabled?: boolean
+ readOnly?: boolean
+ indeterminate?: boolean
+ tooltip?: TooltipProps
+ ref?: (element: HTMLInputElement) => void
+ onInput?: JSX.EventHandler
+ onChange?: JSX.EventHandler
+ onBlur?: JSX.EventHandler
+}
+
+const Option: Component = (props) => {
+ const [rootProps, _addedProps, inputProps] = splitProps(
+ props,
+ ['checked', 'required', 'indeterminate', 'name', 'value', 'required', 'disabled', 'readOnly'],
+ ['tooltip'],
+ )
+
+ return (
+
+
+
+
+ {props.label}
+
+
+
+
+
+
+
+
+ )
+}
+
+export const CheckBox = {
+ Container,
+ Option,
+}
diff --git a/dashboard/src/components/templates/FormItem.tsx b/dashboard/src/components/templates/FormItem.tsx
new file mode 100644
index 000000000..b665b6161
--- /dev/null
+++ b/dashboard/src/components/templates/FormItem.tsx
@@ -0,0 +1,71 @@
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { JSX, ParentComponent, Show } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+import { TooltipProps } from '../UI/ToolTip'
+import { TooltipInfoIcon } from '../UI/TooltipInfoIcon'
+
+export const containerStyle = style({
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+})
+export const TitleContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '2px',
+ },
+})
+export const titleStyle = style({
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+})
+export const RequiredMark = styled('div', {
+ base: {
+ color: colorVars.semantic.accent.error,
+ ...textVars.text.bold,
+ },
+})
+export const errorTextStyle = style({
+ width: '100%',
+ color: colorVars.semantic.accent.error,
+ ...textVars.text.regular,
+})
+const HelpText = styled('div', {
+ base: {
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+
+interface Props {
+ title: string | JSX.Element
+ required?: boolean
+ helpText?: string
+ tooltip?: TooltipProps
+}
+
+export const FormItem: ParentComponent = (props) => {
+ return (
+
+
+ {props.title}
+
+ *
+
+
+
+
+
+ {props.helpText}
+
+
+ {props.children}
+
+ )
+}
diff --git a/dashboard/src/components/templates/Header.tsx b/dashboard/src/components/templates/Header.tsx
new file mode 100644
index 000000000..ad3661294
--- /dev/null
+++ b/dashboard/src/components/templates/Header.tsx
@@ -0,0 +1,98 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import LogoImage from '/@/assets/logo.svg?url'
+import SmallLogoImage from '/@/assets/logo_small.svg?url'
+import { systemInfo, user } from '/@/libs/api'
+import { colorVars, media } from '/@/theme'
+import { Button } from '../UI/Button'
+import { MaterialSymbols } from '../UI/MaterialSymbols'
+import { UserMenuButton } from '../UI/UserMenuButton'
+import MobileNavigation from './MobileNavigation'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '64px',
+ padding: '10px 24px',
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ gap: '12px',
+ borderBottom: `1px solid ${colorVars.semantic.ui.border}`,
+ },
+})
+const NavigationContainer = styled('div', {
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+
+ '@media': {
+ [media.mobile]: {
+ display: 'none',
+ },
+ },
+ },
+})
+const MobileNavigationContainer = styled('div', {
+ base: {
+ display: 'none',
+
+ '@media': {
+ [media.mobile]: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ },
+ },
+})
+const UserMenuButtonContainer = styled('div', {
+ base: {
+ marginLeft: 'auto',
+ },
+})
+
+export const Header: Component = () => {
+ return (
+
+
+
+
+
+ {/* 画面幅が768px以下の時はSmallLogoImageを表示する */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(user) => (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/List.tsx b/dashboard/src/components/templates/List.tsx
new file mode 100644
index 000000000..baedf6c02
--- /dev/null
+++ b/dashboard/src/components/templates/List.tsx
@@ -0,0 +1,126 @@
+import { styled } from '@macaron-css/solid'
+import { Component, For } from 'solid-js'
+import { Application, Build, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { colorVars, textVars } from '/@/theme'
+import { AppRow } from './app/AppRow'
+import { BuildRow } from './build/BuildRow'
+import { RepositoryRow } from './repo/RepositoryRow'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ overflow: 'hidden',
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '1px',
+ background: colorVars.semantic.ui.border,
+ },
+})
+const Row = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+ background: colorVars.semantic.ui.primary,
+ },
+})
+const Columns = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ gap: '1px',
+ },
+})
+const RowContent = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+})
+const RowTitle = styled('h3', {
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ color: colorVars.semantic.text.grey,
+ ...textVars.text.medium,
+ },
+})
+const RowData = styled('div', {
+ base: {
+ width: 'auto',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '4px',
+ overflowWrap: 'anywhere',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+ },
+})
+const PlaceHolder = styled('div', {
+ base: {
+ width: '100%',
+ height: '400px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+ alignItems: 'center',
+ justifyContent: 'center',
+
+ background: colorVars.semantic.ui.primary,
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.medium,
+ },
+})
+
+export const List = {
+ Container,
+ Row,
+ Columns,
+ RowContent,
+ RowTitle,
+ RowData,
+ PlaceHolder,
+}
+
+export const RepositoryList: Component<{
+ repository?: Repository
+ apps: (Application | undefined)[]
+}> = (props) => {
+ return (
+
+
+ {(app) => }
+
+ )
+}
+
+export const AppsList: Component<{ apps: (Application | undefined)[] }> = (props) => {
+ return (
+
+ {(app) => }
+
+ )
+}
+
+export const BuildList: Component<{
+ builds: { build: Build; appName?: string }[]
+ currentBuild?: Build['id']
+}> = (props) => {
+ return (
+
+
+ {(b) => }
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/MobileNavigation.tsx b/dashboard/src/components/templates/MobileNavigation.tsx
new file mode 100644
index 000000000..6cb6d721b
--- /dev/null
+++ b/dashboard/src/components/templates/MobileNavigation.tsx
@@ -0,0 +1,126 @@
+import { Dialog, createDisclosureState } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { A, useIsRouting } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import { createComputed } from 'solid-js'
+import LogoImage from '/@/assets/logo.svg?url'
+import { systemInfo } from '/@/libs/api'
+import { colorVars } from '/@/theme'
+import { Button } from '../UI/Button'
+import { MaterialSymbols } from '../UI/MaterialSymbols'
+
+const buttonStyle = style({
+ width: '32px',
+ height: '32px',
+ display: 'grid',
+ placeItems: 'center',
+ appearance: 'none',
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+})
+const overlayShow = keyframes({
+ from: {
+ opacity: 0,
+ },
+ to: {
+ opacity: 1,
+ },
+})
+const overlayHide = keyframes({
+ from: {
+ opacity: 1,
+ },
+ to: {
+ opacity: 0,
+ },
+})
+const overlayStyle = style({
+ position: 'fixed',
+ inset: 0,
+ background: colorVars.primitive.blackAlpha[600],
+ animation: `${overlayHide} 0.2s`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${overlayShow} 0.2s`,
+ },
+ },
+})
+const contentStyle = style({
+ position: 'fixed',
+ inset: 0,
+ padding: '16px',
+ maxWidth: 'fit-content',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+
+ background: colorVars.semantic.primary.white,
+})
+const DialogHeaderContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ gap: '16px',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+})
+const NavigationContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+})
+
+const MobileNavigation: Component = () => {
+ const { isOpen, setIsOpen, close } = createDisclosureState()
+
+ const isRouting = useIsRouting()
+ createComputed(() => isRouting() && close())
+
+ return (
+
+
+ menu
+
+
+
+
+
+
+
+
+
+ close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default MobileNavigation
diff --git a/dashboard/src/components/templates/Nav.tsx b/dashboard/src/components/templates/Nav.tsx
new file mode 100644
index 000000000..2c06a2cdb
--- /dev/null
+++ b/dashboard/src/components/templates/Nav.tsx
@@ -0,0 +1,98 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { JSX, ParentComponent, Show } from 'solid-js'
+import { Button } from '/@/components/UI/Button'
+import { media, textVars } from '/@/theme'
+import { MaterialSymbols } from '../UI/MaterialSymbols'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ overflowX: 'hidden',
+ padding: '32px 32px 32px 32px',
+ paddingRight: 'max(calc(50% - 500px), 32px)',
+ display: 'flex',
+ gap: '8px',
+
+ '@media': {
+ [media.mobile]: {
+ padding: '32px 16px',
+ },
+ },
+ },
+})
+const BackToTitle = styled('div', {
+ base: {
+ '@media': {
+ [media.mobile]: {
+ display: 'none',
+ },
+ },
+ },
+})
+const TitleStickyContainer = styled('div', {
+ base: {
+ width: '100%',
+ overflowX: 'clip',
+ },
+})
+const TitleContainer = styled('div', {
+ base: {
+ position: 'sticky',
+ width: '100%',
+ maxWidth: '1000px',
+ height: 'auto',
+ left: 'calc(75% - 250px)',
+ overflowX: 'hidden',
+ },
+})
+const Titles = styled('div', {
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ overflowX: 'hidden',
+ },
+})
+const Title = styled('h1', {
+ base: {
+ width: '100%',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ ...textVars.h1.medium,
+ },
+})
+export interface Props {
+ title: string
+ backTo?: string
+ backToTitle?: string
+ icon?: JSX.Element
+ action?: JSX.Element
+}
+
+export const Nav: ParentComponent = (props) => {
+ return (
+
+ }>
+ {(nonNullBackTo) => (
+
+
+
+ )}
+
+
+
+
+ {props.icon}
+ {props.title}
+ {props.action}
+
+ {props.children}
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/OwnerList.tsx b/dashboard/src/components/templates/OwnerList.tsx
new file mode 100644
index 000000000..c9d3ce1f6
--- /dev/null
+++ b/dashboard/src/components/templates/OwnerList.tsx
@@ -0,0 +1,245 @@
+import { styled } from '@macaron-css/solid'
+import Fuse from 'fuse.js'
+import { Component, For, Show, createMemo, createSignal } from 'solid-js'
+import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import UserAvatar from '/@/components/UI/UserAvater'
+import useModal from '/@/libs/useModal'
+import { colorVars, textVars } from '/@/theme'
+import ModalDeleteConfirm from '../UI/ModalDeleteConfirm'
+import { TextField } from '../UI/TextField'
+
+const SearchUserRow = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '16px',
+ },
+})
+const AddOwnersContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ maxHeight: '100%',
+ display: 'grid',
+ gridTemplateRows: 'auto 1fr',
+ gap: '16px',
+ },
+})
+const UsersContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ maxHeight: '100%',
+ overflowY: 'auto',
+ display: 'flex',
+ flexDirection: 'column',
+
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
+ },
+})
+const UserRowContainer = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+
+ selectors: {
+ '&:not(:last-child)': {
+ borderBottom: `1px solid ${colorVars.semantic.ui.border}`,
+ },
+ },
+ },
+})
+const UserName = styled('div', {
+ base: {
+ width: '100%',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.medium,
+ },
+})
+const UserPlaceholder = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: colorVars.semantic.text.grey,
+ ...textVars.text.medium,
+ },
+})
+
+const AddOwners: Component<{
+ nonOwners: User[]
+ addOwner: (user: User) => void
+}> = (props) => {
+ const [searchUserQuery, setSearchUserQuery] = createSignal('')
+ const fuse = createMemo(
+ () =>
+ new Fuse(props.nonOwners, {
+ keys: ['name'],
+ }),
+ )
+ const filteredUsers = createMemo(() => {
+ if (searchUserQuery() === '') {
+ return props.nonOwners
+ } else {
+ return fuse()
+ .search(searchUserQuery())
+ .map((result) => result.item)
+ }
+ })
+
+ return (
+
+ search}
+ value={searchUserQuery()}
+ onInput={(e) => setSearchUserQuery(e.currentTarget.value)}
+ />
+
+
+ {(user) => (
+
+
+ {user.name}
+
+
+ )}
+
+
+ No Users Found
+
+
+
+ )
+}
+
+const OwnerRow: Component<{
+ user: User
+ deleteOwner?: (user: User) => void
+}> = (props) => {
+ const { Modal: DeleteUserModal, open: openDeleteUserModal, close: closeDeleteUserModal } = useModal()
+
+ return (
+ <>
+
+
+ {props.user.name}
+
+
+
+
+
+ Delete Owner
+
+
+
+ {props.user.name}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+const OwnerList: Component<{
+ owners: User[]
+ users: User[]
+ handleAddOwner: (user: User) => Promise
+ handleDeleteOwner: (user: User) => Promise
+ hasPermission: boolean
+}> = (props) => {
+ const [searchUserQuery, setSearchUserQuery] = createSignal('')
+ const fuse = createMemo(
+ () =>
+ new Fuse(props.owners, {
+ keys: ['name'],
+ }),
+ )
+ const filteredOwners = createMemo(() => {
+ if (searchUserQuery() === '') {
+ return props.owners
+ } else {
+ return fuse()
+ .search(searchUserQuery())
+ .map((result) => result.item)
+ }
+ })
+
+ const nonOwners = createMemo(() => props.users.filter((u) => !props.owners.some((o) => o.id === u.id)))
+ const { Modal: AddUserModal, open: openAddUserModal } = useModal({
+ showCloseButton: true,
+ })
+
+ return (
+ <>
+
+ search}
+ value={searchUserQuery()}
+ onInput={(e) => setSearchUserQuery(e.currentTarget.value)}
+ />
+
+
+ Add Owner
+
+
+
+
+
+
+
+ {(owner) => }
+
+
+ No Owners Found
+
+
+ >
+ )
+}
+
+export default OwnerList
diff --git a/dashboard/src/components/templates/RadioGroups.tsx b/dashboard/src/components/templates/RadioGroups.tsx
new file mode 100644
index 000000000..71eeb8b20
--- /dev/null
+++ b/dashboard/src/components/templates/RadioGroups.tsx
@@ -0,0 +1,132 @@
+import { RadioGroup as KRadioGroup } from '@kobalte/core'
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { For, JSX, Show, splitProps } from 'solid-js'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars, textVars } from '/@/theme'
+import { RadioIcon } from '../UI/RadioIcon'
+import { ToolTip, TooltipProps } from '../UI/ToolTip'
+import { TooltipInfoIcon } from '../UI/TooltipInfoIcon'
+import { RequiredMark, TitleContainer, containerStyle, titleStyle } from './FormItem'
+
+const OptionsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '16px',
+ },
+})
+const itemStyle = style({
+ width: 'fit-content',
+ minWidth: 'min(200px, 100%)',
+})
+const labelStyle = style({
+ width: '100%',
+ padding: '16px',
+ display: 'grid',
+ gridTemplateColumns: '1fr 20px',
+ alignItems: 'center',
+ justifyItems: 'start',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+ cursor: 'pointer',
+
+ selectors: {
+ '&:hover:not([data-disabled]):not([data-readonly])': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ '&[data-readonly]': {
+ cursor: 'not-allowed',
+ },
+ '&[data-checked]': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: colorVars.semantic.text.disabled,
+ background: colorVars.semantic.ui.tertiary,
+ },
+ '&[data-invalid]': {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+})
+
+export interface RadioOption {
+ value: T
+ label: string
+}
+
+export interface Props {
+ name?: string
+ error?: string
+ label?: string
+ options: RadioOption[]
+ value?: T
+ setValue?: (value: T) => void
+ required?: boolean
+ disabled?: boolean
+ readOnly?: boolean
+ info?: TooltipProps
+ tooltip?: TooltipProps
+ ref?: (element: HTMLInputElement | HTMLTextAreaElement) => void
+ onInput?: JSX.EventHandler
+ onChange?: JSX.EventHandler
+ onBlur?: JSX.EventHandler
+}
+
+export const RadioGroup = (props: Props): JSX.Element => {
+ const [rootProps, _addedProps, inputProps] = splitProps(
+ props,
+ ['name', 'value', 'options', 'required', 'disabled', 'readOnly'],
+ ['info', 'tooltip', 'setValue'],
+ )
+
+ return (
+ props.setValue?.(v as T)}
+ orientation="horizontal"
+ >
+
+
+ {props.label}
+
+ *
+
+
+
+
+
+
+
+
+
+ {(option) => (
+
+
+
+ {option.label}
+
+
+
+
+
+
+
+ )}
+
+
+
+ {props.error}
+
+ )
+}
diff --git a/dashboard/src/components/templates/Select.tsx b/dashboard/src/components/templates/Select.tsx
new file mode 100644
index 000000000..a8d1d5944
--- /dev/null
+++ b/dashboard/src/components/templates/Select.tsx
@@ -0,0 +1,424 @@
+import { Combobox as KComboBox, Select as KSelect } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
+import { JSX, Show, createMemo, splitProps } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+import { CheckBoxIcon } from '../UI/CheckBoxIcon'
+import { MaterialSymbols } from '../UI/MaterialSymbols'
+import { ToolTip, TooltipProps } from '../UI/ToolTip'
+import { TooltipInfoIcon } from '../UI/TooltipInfoIcon'
+import { RequiredMark, TitleContainer, containerStyle, errorTextStyle, titleStyle } from './FormItem'
+
+const itemStyleBase = style({
+ width: '100%',
+ height: '44px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+
+ background: 'none',
+ border: 'none',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ whiteSpace: 'nowrap',
+ ...textVars.text.bold,
+
+ selectors: {
+ '&:hover, &[data-highlighted]': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+})
+const singleItemStyle = style([
+ itemStyleBase,
+ {
+ padding: '8px 16px',
+ selectors: {
+ '&[data-selected]': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+ },
+])
+const multiItemStyle = style([
+ itemStyleBase,
+ {
+ padding: '8px',
+ },
+])
+const triggerStyle = style({
+ width: '100%',
+ maxWidth: '288px',
+ height: '48px',
+ padding: '10px 16px',
+ display: 'grid',
+ gridTemplateColumns: '1fr 24px',
+ alignContent: 'center',
+ alignItems: 'center',
+ gap: '4px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: 'none',
+ outline: `1px solid ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ cursor: 'pointer',
+
+ selectors: {
+ '&:focus-visible': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-expanded]': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: colorVars.semantic.text.disabled,
+ background: colorVars.semantic.ui.tertiary,
+ },
+ },
+})
+const valueStyle = style({
+ width: '100%',
+ ...textVars.text.regular,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ textAlign: 'left',
+ selectors: {
+ '&[data-placeholder-shown]': {
+ color: colorVars.semantic.text.disabled,
+ },
+ },
+})
+const iconStyle = style({
+ width: '24px',
+ height: '24px',
+ flexShrink: 0,
+})
+const contentShowKeyframes = keyframes({
+ from: { opacity: 0, transform: 'translateY(-8px)' },
+ to: { opacity: 1, transform: 'translateY(0)' },
+})
+const contentHideKeyframes = keyframes({
+ from: { opacity: 1, transform: 'translateY(0)' },
+ to: { opacity: 0, transform: 'translateY(-8px)' },
+})
+const contentStyleBase = style({
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '6px',
+ boxShadow: '0px 0px 20px 0px rgba(0, 0, 0, 0.10)',
+ animation: `${contentHideKeyframes} 0.2s ease-out`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${contentShowKeyframes} 0.2s ease-out`,
+ },
+ },
+})
+const selectContentStyle = style([
+ contentStyleBase,
+ {
+ transformOrigin: 'var(--kb-select-content-transform-origin)',
+ },
+])
+const comboBoxContentStyle = style([
+ contentStyleBase,
+ {
+ maxWidth: '288px',
+ transformOrigin: 'var(--kb-combobox-content-transform-origin)',
+ },
+])
+const listBoxStyle = style({
+ padding: '6px',
+ maxHeight: '400px',
+ overflowY: 'auto',
+})
+
+export type SelectOption = {
+ label: string
+ value: T
+}
+
+type SelectProps = {
+ name?: string
+ error?: string
+ label?: string
+ placeholder?: string
+ options: SelectOption[]
+ required?: boolean
+ disabled?: boolean
+ readOnly?: boolean
+ info?: TooltipProps
+ tooltip?: TooltipProps
+ ref?: (element: HTMLSelectElement) => void
+ onInput?: JSX.EventHandler
+ onChange?: JSX.EventHandler
+ onBlur?: JSX.EventHandler
+}
+
+export type SingleSelectProps = SelectProps & {
+ value: T | undefined
+ setValue?: (v: T) => void
+}
+
+export const SingleSelect = (props: SingleSelectProps): JSX.Element => {
+ const [rootProps, selectProps] = splitProps(
+ props,
+ ['name', 'placeholder', 'options', 'required', 'disabled', 'readOnly'],
+ ['placeholder', 'ref', 'onInput', 'onChange', 'onBlur'],
+ )
+
+ const selectedOption = () => props.options.find((o) => o.value === props.value)
+
+ return (
+ >
+ class={containerStyle}
+ {...rootProps}
+ multiple={false}
+ disallowEmptySelection
+ value={selectedOption()}
+ onChange={(v) => props.setValue?.(v.value)}
+ optionValue="value"
+ optionTextValue="label"
+ validationState={props.error ? 'invalid' : 'valid'}
+ itemComponent={(props) => (
+
+ {props.item.textValue}
+
+ )}
+ >
+
+
+ {props.label}
+
+ *
+
+
+
+
+
+
+
+
+
+ > class={valueStyle}>{(state) => state.selectedOption().label}
+
+ expand_more
+
+
+
+
+
+
+
+
+ {props.error}
+
+ )
+}
+
+export type MultiSelectProps = SelectProps & {
+ value: T[] | undefined
+ setValue?: (v: T[]) => void
+}
+
+export const MultiSelect = (props: MultiSelectProps): JSX.Element => {
+ const [rootProps, selectProps] = splitProps(
+ props,
+ ['name', 'placeholder', 'options', 'required', 'disabled', 'readOnly'],
+ ['placeholder', 'ref', 'onInput', 'onChange', 'onBlur'],
+ )
+
+ const selectedOptions = () => props.options.filter((o) => props.value?.some((v) => v === o.value))
+
+ return (
+ >
+ class={containerStyle}
+ {...rootProps}
+ multiple={true}
+ value={selectedOptions()}
+ onChange={(newValues) => props.setValue?.(newValues.map((v) => v.value))}
+ optionValue="value"
+ optionTextValue="label"
+ validationState={props.error ? 'invalid' : 'valid'}
+ itemComponent={(itemProps) => (
+
+
+ v === itemProps.item.textValue) ?? false} />
+
+ {itemProps.item.textValue}
+
+ )}
+ >
+
+
+ {props.label}
+
+ *
+
+
+
+
+
+
+
+
+ > class={valueStyle}>
+ {(state) =>
+ state
+ .selectedOptions()
+ .map((v) => v.label)
+ .join(', ')
+ }
+
+
+ expand_more
+
+
+
+
+
+
+
+ {props.error}
+
+ )
+}
+
+const controlStyle = style({
+ position: 'relative',
+ width: '100%',
+ maxWidth: '288px',
+ display: 'flex',
+ gap: '1px',
+})
+const comboBoxTriggerStyle = style({
+ color: colorVars.semantic.text.disabled,
+ position: 'absolute',
+ width: '44px',
+ height: '100%',
+ right: '0',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ paddingLeft: '4px',
+ border: 'none',
+ background: 'none',
+ cursor: 'pointer',
+})
+const comboBoxInputStyle = style({
+ width: '100%',
+ height: '48px',
+ padding: '0 16px',
+ display: 'flex',
+ gap: '4px',
+ paddingRight: '44px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: 'none',
+ outline: `1px solid ${colorVars.semantic.ui.border}`,
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+
+ selectors: {
+ '&::placeholder': {
+ color: colorVars.semantic.text.disabled,
+ },
+ '&:focus-visible': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ background: colorVars.semantic.ui.tertiary,
+ },
+ '&[data-invalid]': {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+})
+
+export type ComboBoxProps = SelectProps & {
+ value: T | undefined
+ setValue?: (v: T) => void
+}
+
+export const ComboBox = (props: SingleSelectProps): JSX.Element => {
+ const [rootProps, selectProps] = splitProps(
+ props,
+ ['name', 'placeholder', 'options', 'required', 'disabled', 'readOnly'],
+ ['placeholder', 'ref', 'onInput', 'onChange', 'onBlur'],
+ )
+
+ const selectedOption = createMemo>(
+ (prev) => {
+ const find = props.options.find((o) => o.value === props.value)
+ if (find) {
+ props.setValue?.(find.value)
+ return find
+ } else {
+ props.setValue?.(prev.value)
+ return prev
+ }
+ },
+ { label: props.value?.toString() ?? '', value: props.value ?? ('' as T) },
+ )
+
+ return (
+ >
+ class={containerStyle}
+ {...rootProps}
+ multiple={false}
+ disallowEmptySelection
+ value={selectedOption()}
+ onChange={(v) => {
+ props.setValue?.(v.value)
+ }}
+ optionValue="value"
+ optionTextValue="label"
+ optionLabel="label"
+ triggerMode="input"
+ validationState={props.error ? 'invalid' : 'valid'}
+ itemComponent={(props) => (
+
+ {props.item.textValue}
+
+ )}
+ >
+
+
+ {props.label}
+
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+ expand_more
+
+
+
+
+
+
+
+
+
+ {props.error}
+
+ )
+}
diff --git a/dashboard/src/components/templates/SettingSkeleton.tsx b/dashboard/src/components/templates/SettingSkeleton.tsx
new file mode 100644
index 000000000..24413905d
--- /dev/null
+++ b/dashboard/src/components/templates/SettingSkeleton.tsx
@@ -0,0 +1,35 @@
+import { Component } from 'solid-js'
+import { Button } from '../UI/Button'
+import Skeleton from '../UI/Skeleton'
+import { DataTable } from '../layouts/DataTable'
+import FormBox from '../layouts/FormBox'
+import { FormItem } from './FormItem'
+
+const SettingSkeleton: Component = () => {
+ return (
+
+
+ Config Title
+
+
+
+ Form Label}>
+
+
+ Second Form Label}>
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SettingSkeleton
diff --git a/dashboard/src/components/templates/app/AppConfigInfo.tsx b/dashboard/src/components/templates/app/AppConfigInfo.tsx
new file mode 100644
index 000000000..7ae8d2a89
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppConfigInfo.tsx
@@ -0,0 +1,180 @@
+import { Component, Match, Show, Switch } from 'solid-js'
+import {
+ ApplicationConfig,
+ BuildConfigRuntimeBuildpack,
+ BuildConfigRuntimeCmd,
+ BuildConfigRuntimeDockerfile,
+ BuildConfigStaticBuildpack,
+ BuildConfigStaticCmd,
+ BuildConfigStaticDockerfile,
+ RuntimeConfig,
+ StaticConfig,
+} from '/@/api/neoshowcase/protobuf/gateway_pb'
+import Code from '/@/components/UI/Code'
+import { List } from '../List'
+
+const BuildpackConfigInfo: Component<{ config: BuildConfigRuntimeBuildpack | BuildConfigStaticBuildpack }> = (
+ props,
+) => {
+ return (
+
+
+ Context
+
+
+
+ )
+}
+const CmdConfigInfo: Component<{ config: BuildConfigRuntimeCmd | BuildConfigStaticCmd }> = (props) => {
+ return (
+ <>
+
+
+ Base Image
+ {props.config.baseImage}
+
+
+
+
+
+ Build Command
+
+
+
+
+ >
+ )
+}
+const DockerfileConfigInfo: Component<{ config: BuildConfigRuntimeDockerfile | BuildConfigStaticDockerfile }> = (
+ props,
+) => {
+ return (
+ <>
+
+
+ Dockerfile
+ {props.config.dockerfileName}
+
+
+
+
+ Context
+
+
+
+ >
+ )
+}
+
+const RuntimeConfigInfo: Component<{ config?: RuntimeConfig }> = (props) => {
+ return (
+
+
+
+
+ Use MariaDB
+ {`${props.config!.useMariadb}`}
+
+
+
+
+ Use MongoDB
+ {`${props.config!.useMongodb}`}
+
+
+
+
+
+
+ Entrypoint
+
+
+
+
+
+
+
+ Command
+
+
+
+
+
+ )
+}
+const StaticConfigInfo: Component<{ config?: StaticConfig }> = (props) => {
+ return (
+
+
+
+ Artifact Path
+
+
+
+
+
+ Single Page Application
+ {`${props.config!.spa}`}
+
+
+
+ )
+}
+
+const AppConfigInfo: Component<{ config: ApplicationConfig }> = (props) => {
+ const c = props.config.buildConfig
+ return (
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ {(c) => (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
+
+export default AppConfigInfo
diff --git a/dashboard/src/components/templates/app/AppDeployInfo.tsx b/dashboard/src/components/templates/app/AppDeployInfo.tsx
new file mode 100644
index 000000000..00fa9a60f
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppDeployInfo.tsx
@@ -0,0 +1,256 @@
+import { styled } from '@macaron-css/solid'
+import { Component, For, Show, createSignal } from 'solid-js'
+import toast from 'solid-toast'
+import { Application, Build, DeployType, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import Badge from '/@/components/UI/Badge'
+import { Button } from '/@/components/UI/Button'
+import JumpButton from '/@/components/UI/JumpButton'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { URLText } from '/@/components/UI/URLText'
+import { client, handleAPIError } from '/@/libs/api'
+import { deploymentState, getWebsiteURL } from '/@/libs/application'
+import { titleCase } from '/@/libs/casing'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { diffHuman, shortSha } from '/@/libs/format'
+import { colorVars, media, textVars } from '/@/theme'
+import { List } from '../List'
+import { AppStatusIcon } from './AppStatusIcon'
+
+const DeploymentContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'grid',
+ gridTemplateColumns: '1fr 2fr',
+ gridTemplateRows: 'auto',
+ gap: '1px',
+
+ background: colorVars.semantic.ui.border,
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
+ overflow: 'hidden',
+
+ '@media': {
+ [media.mobile]: {
+ gridTemplateColumns: '1fr',
+ },
+ },
+ },
+})
+const AppStateContainer = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ display: 'grid',
+ gridTemplateRows: '1fr 2fr 1fr',
+ justifyItems: 'center',
+
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ ...textVars.h3.medium,
+ },
+ variants: {
+ variant: {
+ Running: {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.successSelected),
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.successHover),
+ },
+ },
+ },
+ Serving: {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primarySelected),
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ },
+ },
+ Idle: {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.primitive.blackAlpha[200]),
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.primitive.blackAlpha[100]),
+ },
+ },
+ },
+ Deploying: {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.warnSelected),
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.warnHover),
+ },
+ },
+ },
+ Error: {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.errorSelected),
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.errorHover),
+ },
+ },
+ },
+ },
+ },
+})
+const AppState = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '8px',
+ },
+})
+const InfoContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ display: 'grid',
+ gridTemplateColumns: 'repeat(2, 1fr)',
+ gridTemplateRows: 'auto',
+ gap: '1px',
+ },
+})
+const ActionButtons = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+ },
+})
+const DeployInfo = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ },
+ variants: {
+ long: {
+ true: {
+ gridColumn: 'span 2',
+ },
+ },
+ },
+})
+const AppDeployInfo: Component<{
+ app: Application
+ refetch: () => Promise
+ repo: Repository
+ startApp: () => Promise
+ deployedBuild: Build | undefined
+ latestBuildId: string | undefined
+ hasPermission: boolean
+}> = (props) => {
+ const [mouseEnter, setMouseEnter] = createSignal(false)
+ const showActions = () => props.hasPermission && mouseEnter()
+
+ const stopApp = async () => {
+ try {
+ await client.stopApplication({ id: props.app.id })
+ await props.refetch()
+ toast.success('アプリケーションを停止しました')
+ } catch (e) {
+ handleAPIError(e, 'アプリケーションの停止に失敗しました')
+ }
+ }
+
+ return (
+
+ setMouseEnter(true)}
+ onMouseLeave={() => setMouseEnter(false)}
+ variant={deploymentState(props.app)}
+ >
+
+
+
+ {deploymentState(props.app)}
+
+
+
+
+
+
+
+
+
+
+
+ 起動時刻
+
+ {(nonNullUpdatedAt) => {
+ const { diff, localeString } = diffHuman(nonNullUpdatedAt().toDate())
+
+ return (
+
+ {diff}
+
+ )
+ }}
+
+
+
+
+
+ Deploy Type
+ {titleCase(DeployType[props.app.deployType])}
+
+
+
+
+
+ Source Commit
+
+ {`${props.deployedBuild?.commit ? shortSha(props.deployedBuild?.commit) : '0000000'}`}
+
+
+ Latest
+
+
+
+
+
+
+
+
+
+
+
+ URLs
+ {props.app.websites.length}
+
+
+ {(url) => (
+
+
+
+ )}
+
+
+
+
+
+
+
+ Container Status
+ {props.app.containerMessage}
+
+
+
+
+
+ )
+}
+
+export default AppDeployInfo
diff --git a/dashboard/src/components/templates/app/AppGeneralConfig.tsx b/dashboard/src/components/templates/app/AppGeneralConfig.tsx
new file mode 100644
index 000000000..08b881230
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppGeneralConfig.tsx
@@ -0,0 +1,88 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { Field, FormStore, required, setValue } from '@modular-forms/solid'
+import { Component, Show } from 'solid-js'
+import { CreateApplicationRequest, Repository, UpdateApplicationRequest } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { TextField } from '/@/components/UI/TextField'
+import { useBranches } from '/@/libs/branchesSuggestion'
+import { ComboBox } from '../Select'
+
+export type AppGeneralForm = Pick<
+ PlainMessage | PlainMessage,
+ 'name' | 'repositoryId' | 'refName'
+>
+
+interface GeneralConfigProps {
+ repo: Repository
+ formStore: FormStore
+ editBranchId?: boolean
+ hasPermission: boolean
+}
+
+export const AppGeneralConfig: Component = (props) => {
+ const branches = useBranches(() => props.repo.id)
+
+ return (
+ <>
+
+ {(field, fieldProps) => (
+
+ )}
+
+
+ {(field, fieldProps) => (
+
+
+
+ )}
+
+
+ {(field, fieldProps) => (
+
+ Gitブランチ名またはRef
+ 入力欄をクリックして候補を表示
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ options={branches().map((branch) => ({
+ label: branch,
+ value: branch,
+ }))}
+ value={field.value}
+ error={field.error}
+ setValue={(v) => {
+ setValue(props.formStore, 'refName', v)
+ }}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+ >
+ )
+}
diff --git a/dashboard/src/components/templates/app/AppInfoLists.tsx b/dashboard/src/components/templates/app/AppInfoLists.tsx
new file mode 100644
index 000000000..49a314c97
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppInfoLists.tsx
@@ -0,0 +1,74 @@
+import { Component, Show } from 'solid-js'
+import { Application, DeployType } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import Code from '/@/components/UI/Code'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { systemInfo } from '/@/libs/api'
+import { diffHuman, shortSha } from '/@/libs/format'
+import { List } from '../List'
+
+import { Button } from '/@/components/UI/Button'
+import { ApplicationState, deploymentState } from '/@/libs/application'
+
+const AppInfoLists: Component<{
+ app: Application
+ refreshCommit: () => void
+ disableRefreshCommit: boolean
+ hasPermission: boolean
+}> = (props) => {
+ const sshAccessCommand = () => `ssh -p ${systemInfo()?.ssh?.port} ${props.app.id}@${systemInfo()?.ssh?.host}`
+
+ return (
+ <>
+
+
+ {(nonNullCreatedAt) => {
+ const { diff, localeString } = diffHuman(nonNullCreatedAt().toDate())
+ return (
+
+
+ 作成日
+
+ {diff}
+
+
+
+ )
+ }}
+
+
+
+ Branch (Commit)
+ {`${props.app.refName} (${shortSha(props.app.commit)})`}
+
+
+
+
+
+
+
+
+ SSH Access
+
+
+ 現在アプリが起動していないためSSHアクセスはできません
+
+
+
+
+
+ >
+ )
+}
+export default AppInfoLists
diff --git a/dashboard/src/components/templates/app/AppLatestBuilds.tsx b/dashboard/src/components/templates/app/AppLatestBuilds.tsx
new file mode 100644
index 000000000..8642bf718
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppLatestBuilds.tsx
@@ -0,0 +1,55 @@
+import { Component, For, createSignal } from 'solid-js'
+
+import { Application, Build, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+
+import { List } from '../List'
+import { BuildRow } from '../build/BuildRow'
+
+const AppLatestBuilds: Component<{
+ app: Application
+ refetch: () => Promise
+ repo: Repository
+ startApp: () => Promise
+ sortedBuilds: Build[]
+ hasPermission: boolean
+}> = (props) => {
+ const [disabled, setDisabled] = createSignal(false)
+
+ const startApp = async () => {
+ setDisabled(true)
+ await props.startApp()
+ setDisabled(false)
+ }
+
+ // 最新5件のビルド
+ const latestBuilds = () => props.sortedBuilds.slice(0, 4)
+
+ return (
+
+
+ deployed_code
+ No Builds
+
+
+ }
+ >
+ {(build) => }
+
+
+ )
+}
+
+export default AppLatestBuilds
diff --git a/dashboard/src/components/AppMetrics.tsx b/dashboard/src/components/templates/app/AppMetrics.tsx
similarity index 74%
rename from dashboard/src/components/AppMetrics.tsx
rename to dashboard/src/components/templates/app/AppMetrics.tsx
index 10f239b0d..f9f5fb7e6 100644
--- a/dashboard/src/components/AppMetrics.tsx
+++ b/dashboard/src/components/templates/app/AppMetrics.tsx
@@ -1,10 +1,10 @@
-import { client } from '/@/libs/api'
-import { formatBytes, formatPercent } from '/@/libs/format'
import { Timestamp } from '@bufbuild/protobuf'
-import { Chart, ChartData, ChartOptions, Colors, Filler, Legend, Title, Tooltip } from 'chart.js'
+import { CartesianTickOptions, Chart, ChartData, ChartOptions, Colors, Filler, Legend, Title, Tooltip } from 'chart.js'
import { Line } from 'solid-chartjs'
import { Component, Show, createEffect, createMemo, mergeProps, onCleanup, splitProps } from 'solid-js'
import { createResource } from 'solid-js'
+import { client } from '/@/libs/api'
+import { formatBytes, formatPercent } from '/@/libs/format'
Chart.register(Title, Tooltip, Legend, Colors, Filler)
@@ -50,25 +50,30 @@ export const AppMetrics: Component = (props) => {
const refetchTimer = setInterval(refetchData, 60000)
onCleanup(() => clearInterval(refetchTimer))
- const maxDataVal = createMemo(() => data() && Math.max(...data().metrics.map((m) => m.value)))
+ const maxDataVal = createMemo(() => (data.latest !== undefined ? Math.max(...data().metrics.map((m) => m.value)) : 0))
const chartData = (): ChartData => {
- if (!data()) return
- const labels = data().metrics.map((m) => m.time.toDate().toLocaleTimeString())
- const values = data().metrics.map((m) => m.value)
+ if (data.latest !== undefined) {
+ const labels = data().metrics.map((m) => m.time?.toDate().toLocaleTimeString())
+ const values = data().metrics.map((m) => m.value)
+ return {
+ labels,
+ datasets: [
+ {
+ label: basicProps.metricsName,
+ data: values,
+ },
+ ],
+ }
+ }
return {
- labels,
- datasets: [
- {
- label: basicProps.metricsName,
- data: values,
- },
- ],
+ datasets: [],
}
}
createEffect(() => {
console.log(`max data val: ${maxDataVal()}`)
})
const chartOptions = (): ChartOptions => ({
+ animation: false,
responsive: true,
maintainAspectRatio: false,
elements: {
@@ -81,7 +86,7 @@ export const AppMetrics: Component = (props) => {
min: options.min,
max: options.max ? Math.min(maxDataVal() * 1.2 || options.max, options.max) : maxDataVal() * 1.2,
ticks: {
- callback: options.yLabel,
+ callback: options.yLabel as CartesianTickOptions['callback'],
},
},
},
@@ -98,9 +103,7 @@ export const AppMetrics: Component = (props) => {
return (
-
-
-
+
)
}
diff --git a/dashboard/src/components/templates/app/AppNav.tsx b/dashboard/src/components/templates/app/AppNav.tsx
new file mode 100644
index 000000000..940890410
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppNav.tsx
@@ -0,0 +1,65 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component } from 'solid-js'
+import { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
+import { colorVars, textVars } from '/@/theme'
+import { Nav } from '../Nav'
+
+const RepositoryInfoContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+ marginTop: '4px',
+ whiteSpace: 'nowrap',
+
+ color: colorVars.semantic.text.black,
+ ...textVars.text.regular,
+ },
+})
+const RepositoryInfo = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '4px',
+ overflowX: 'hidden',
+ },
+})
+const RepositoryName = styled('div', {
+ base: {
+ width: '100%',
+ overflowX: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+})
+
+export const AppNav: Component<{
+ app: Application
+ repository: Repository
+}> = (props) => {
+ return (
+
+ )
+}
diff --git a/dashboard/src/components/templates/app/AppRow.tsx b/dashboard/src/components/templates/app/AppRow.tsx
new file mode 100644
index 000000000..ba3308038
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppRow.tsx
@@ -0,0 +1,157 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import { Application } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import Badge from '/@/components/UI/Badge'
+import Skeleton from '/@/components/UI/Skeleton'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { applicationState, getWebsiteURL } from '/@/libs/application'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { diffHuman, shortSha } from '/@/libs/format'
+import { colorVars, textVars } from '/@/theme'
+import { AppStatusIcon } from './AppStatusIcon'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 16px 16px 20px',
+ cursor: 'pointer',
+ background: colorVars.semantic.ui.primary,
+
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.primitive.blackAlpha[50]),
+ },
+ },
+ },
+ variants: {
+ dark: {
+ true: {
+ background: colorVars.semantic.ui.secondary,
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.secondary, colorVars.primitive.blackAlpha[50]),
+ },
+ },
+ },
+ },
+ },
+})
+const TitleContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ },
+})
+const AppName = styled('div', {
+ base: {
+ width: '100%',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.regular,
+ },
+})
+const UpdatedAt = styled('div', {
+ base: {
+ flexShrink: 0,
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const MetaContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '0 0 0 32px',
+
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const LastCommitName = styled('div', {
+ base: {
+ width: 'fit-content',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+})
+const UrlContainer = styled('div', {
+ base: {
+ width: 'fit-content',
+ marginLeft: 'auto',
+ textAlign: 'right',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+})
+
+const AppRowSkeleton: Component<{
+ dark?: boolean
+}> = (props) => {
+ return (
+
+
+
+
+ App Name Placeholder
+
+
+ 1 day ago
+
+
+
+ 0000000
+
+ https://example.com
+
+
+
+ )
+}
+
+export interface Props {
+ app?: Application
+ dark?: boolean
+}
+
+export const AppRow: Component = (props) => {
+ return (
+ }>
+
+
+
+
+ {props.app!.name}
+
+ {(nonNullUpdatedAt) => {
+ const { diff, localeString } = diffHuman(nonNullUpdatedAt().toDate())
+ return (
+
+ {diff}
+
+ )
+ }}
+
+
+
+ {shortSha(props.app!.commit)}
+ 0}>
+ {getWebsiteURL(props.app!.websites[0])}
+ 1}>
+ {`+${props.app!.websites.length - 1}`}
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/app/AppStatusIcon.tsx b/dashboard/src/components/templates/app/AppStatusIcon.tsx
new file mode 100644
index 000000000..403d93dc7
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppStatusIcon.tsx
@@ -0,0 +1,56 @@
+import { JSXElement } from 'solid-js'
+import { Dynamic } from 'solid-js/web'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { ApplicationState } from '/@/libs/application'
+import { colorVars } from '/@/theme'
+
+interface IconProps {
+ size: number
+}
+const components: Record JSXElement> = {
+ [ApplicationState.Deploying]: (props) => (
+
+ offline_bolt
+
+ ),
+ [ApplicationState.Error]: (props) => (
+
+ error
+
+ ),
+ [ApplicationState.Idle]: (props) => (
+
+ do_not_disturb_on
+
+ ),
+ [ApplicationState.Running]: (props) => (
+
+ check_circle
+
+ ),
+ [ApplicationState.Serving]: (props) => (
+
+ check_circle
+
+ ),
+}
+
+interface Props {
+ state: ApplicationState
+ size?: number
+ hideTooltip?: boolean
+}
+
+export const AppStatusIcon = (props: Props): JSXElement => {
+ return (
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/app/AppsFilter.tsx b/dashboard/src/components/templates/app/AppsFilter.tsx
new file mode 100644
index 000000000..c9d0fe407
--- /dev/null
+++ b/dashboard/src/components/templates/app/AppsFilter.tsx
@@ -0,0 +1,293 @@
+import { As, Checkbox, DropdownMenu, RadioGroup } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Component, For, Setter, Show } from 'solid-js'
+import { CheckBoxIcon } from '/@/components/UI/CheckBoxIcon'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { RadioIcon } from '/@/components/UI/RadioIcon'
+import { ApplicationState, Provider, providerToIcon } from '/@/libs/application'
+import { allProviders, allStatuses, sortItems } from '/@/pages/apps'
+import { colorVars, textVars } from '/@/theme'
+import { AppStatusIcon } from './AppStatusIcon'
+
+const contentShowKeyframes = keyframes({
+ from: { opacity: 0, transform: 'translateY(-8px)' },
+ to: { opacity: 1, transform: 'translateY(0)' },
+})
+const contentHideKeyframes = keyframes({
+ from: { opacity: 1, transform: 'translateY(0)' },
+ to: { opacity: 0, transform: 'translateY(-8px)' },
+})
+const contentStyle = style({
+ padding: '16px',
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 1fr)',
+ gridTemplateRows: '1fr auto',
+ gridTemplateAreas: `
+ "status provider sort"
+ "status noapp noapp"
+ `,
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '6px',
+ boxShadow: '0px 0px 20px 0px rgba(0, 0, 0, 0.10)',
+ zIndex: 1,
+
+ transformOrigin: 'var(--kb-menu-content-transform-origin)',
+ animation: `${contentHideKeyframes} 0.2s ease-in-out`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${contentShowKeyframes} 0.2s ease-in-out`,
+ },
+ },
+})
+const indicatorStyle = style({
+ width: '24px',
+ height: '24px',
+})
+const ItemsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+})
+const SelectItemStyle = style({
+ width: '100%',
+ height: '44px',
+ padding: '8px',
+ display: 'flex',
+ flexWrap: 'nowrap',
+ alignItems: 'center',
+ gap: '8px',
+
+ background: 'none',
+ border: 'none',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ whiteSpace: 'nowrap',
+ ...textVars.text.bold,
+
+ selectors: {
+ '&:hover, &[data-highlighted]': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+})
+const RadioItemStyle = style([
+ SelectItemStyle,
+ {
+ selectors: {
+ '&[data-selected]': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+ },
+])
+const FilterItemContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+ },
+})
+const FilterButton = style({
+ padding: '8px',
+ display: 'flex',
+ background: 'none',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+})
+const IconContainer = styled('div', {
+ base: {
+ position: 'relative',
+ width: '24px',
+ height: '24px',
+ },
+})
+const iconStyle = style({
+ width: '24px',
+ height: '24px',
+ transition: 'transform 0.2s',
+ selectors: {
+ '&[data-expanded]': {
+ transform: 'rotate(180deg)',
+ },
+ },
+})
+const FilterIndicator = styled('div', {
+ base: {
+ position: 'absolute',
+ width: '8px',
+ height: '8px',
+ right: '-2px',
+ top: '-2px',
+ borderRadius: '4px',
+ background: colorVars.semantic.primary.main,
+ outline: `1px solid ${colorVars.semantic.ui.background}`,
+ },
+})
+
+const AppsFilter: Component<{
+ statuses: ApplicationState[]
+ setStatues: Setter
+ provider: Provider[]
+ setProvider: Setter
+ sort: keyof typeof sortItems
+ setSort: Setter
+ includeNoApp: boolean
+ setIncludeNoApp: Setter
+}> = (props) => {
+ const filtered = () =>
+ props.statuses.length !== allStatuses.length || props.provider.length !== allProviders.length || props.includeNoApp
+
+ return (
+
+
+
+ tune
+
+
+
+
+
+ expand_more
+
+
+
+
+
+ Status
+
+
+ {(s) => (
+ {
+ if (selected) {
+ props.setStatues([...props.statuses, s.value])
+ } else {
+ props.setStatues(props.statuses.filter((v) => v !== s.value))
+ }
+ }}
+ >
+
+
+
+
+
+
+ {s.label}
+
+
+ )}
+
+
+
+
+ Provider
+
+
+ {(s) => (
+ {
+ if (selected) {
+ props.setProvider([...props.provider, s.value])
+ } else {
+ props.setProvider(props.provider.filter((v) => v !== s.value))
+ }
+ }}
+ >
+
+
+
+
+
+ {providerToIcon(s.value)}
+ {s.label}
+
+
+ )}
+
+
+
+
+
+ Sort
+
+
+ {(s) => (
+
+
+
+
+
+
+ {s.label}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ アプリを持たないリポジトリを表示
+
+
+
+
+
+
+ )
+}
+
+export default AppsFilter
diff --git a/dashboard/src/components/templates/app/BuildConfigs.tsx b/dashboard/src/components/templates/app/BuildConfigs.tsx
new file mode 100644
index 000000000..2776da8f8
--- /dev/null
+++ b/dashboard/src/components/templates/app/BuildConfigs.tsx
@@ -0,0 +1,582 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { Field, FormStore, getValue, required, setValue } from '@modular-forms/solid'
+import { Component, Match, Show, Switch, createSignal } from 'solid-js'
+import { ApplicationConfig, RuntimeConfig, StaticConfig } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { TextField } from '/@/components/UI/TextField'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { CheckBox } from '../CheckBox'
+import { FormItem } from '../FormItem'
+import { RadioGroup } from '../RadioGroups'
+
+import { createEffect } from 'solid-js'
+import SelectBuildType from './SelectBuildType'
+
+export type BuildConfigMethod = Exclude
+
+interface RuntimeConfigProps {
+ formStore: FormStore
+ disableEditDB: boolean
+ hasPermission: boolean
+}
+
+const RuntimeConfigs: Component = (props) => {
+ const [useDB, setUseDB] = createSignal(false)
+ const buildType = () => getValue(props.formStore, 'case')
+
+ createEffect(() => {
+ if (
+ getValue(props.formStore, 'config.runtimeConfig.useMariadb', { shouldActive: false }) ||
+ getValue(props.formStore, 'config.runtimeConfig.useMongodb', { shouldActive: false })
+ ) {
+ setUseDB(true)
+ }
+ })
+
+ const entrypointConfig = (
+
+ {(field, fieldProps) => (
+
+ )}
+
+ )
+ const commandOverrideConfig = (
+
+ {(field, fieldProps) => (
+
+ )}
+
+ )
+
+ return (
+ <>
+
+
+ データーベースを使用する場合はチェック
+ >
+ ),
+ },
+ }}
+ >
+ アプリ作成後は変更できません>,
+ },
+ disabled: !props.disableEditDB,
+ }}
+ options={[
+ { value: 'true', label: 'Yes' },
+ { value: 'false', label: 'No' },
+ ]}
+ value={useDB() ? 'true' : 'false'}
+ setValue={(v) => setUseDB(v === 'true')}
+ disabled={props.disableEditDB}
+ />
+
+
+
+
+ アプリ作成後は変更できません>,
+ }}
+ >
+
+
+ {(field, fieldProps) => (
+
+ )}
+
+
+ {(field, fieldProps) => (
+
+ )}
+
+
+
+
+
+ {entrypointConfig}
+
+ {entrypointConfig}
+ {commandOverrideConfig}
+
+ >
+ )
+}
+
+interface StaticConfigProps {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+const StaticConfigs = (props: StaticConfigProps) => {
+ return (
+ <>
+
+ {(field, fieldProps) => (
+
+ 静的ファイルが生成されるディレクトリ
+ (Contextからの相対パス)
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+
+ {(field, fieldProps) => (
+
+ 配信するファイルがSPAである
+ (いい感じのフォールバック設定が付きます)
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ options={[
+ { value: 'true', label: 'Yes' },
+ { value: 'false', label: 'No' },
+ ]}
+ value={field.value ? 'true' : 'false'}
+ setValue={(v) => setValue(props.formStore, field.name, v === 'true')}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+ >
+ )
+}
+interface BuildPackConfigProps {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+const BuildPackConfigs = (props: BuildPackConfigProps) => {
+ return (
+
+ {(field, fieldProps) => (
+
+ ビルド対象ディレクトリ
+ (リポジトリルートからの相対パス)
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+ )
+}
+interface CmdConfigProps {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+const CmdConfigs = (props: CmdConfigProps) => {
+ return (
+ <>
+
+ {(field, fieldProps) => (
+
+ ベースとなるDocker Image
+ 「イメージ名:タグ名」の形式
+ ビルドが必要無い場合は空
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+
+ {(field, fieldProps) => (
+
+ イメージ上でビルド時に実行するコマンド
+ リポジトリルートで実行されます
+ >
+ ),
+ },
+ }}
+ {...fieldProps}
+ multiline
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+ >
+ )
+}
+interface DockerConfigProps {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+const DockerConfigs = (props: DockerConfigProps) => {
+ return (
+ <>
+
+ {(field, fieldProps) => (
+
+ ビルドContext
+ (リポジトリルートからの相対パス)
+ >
+ ),
+ },
+ }}
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ {...fieldProps}
+ />
+ )}
+
+
+ {(field, fieldProps) => (
+
+ Dockerfileへのパス
+ (Contextからの相対パス)
+ >
+ ),
+ },
+ }}
+ value={field.value ?? ''}
+ error={field.error}
+ readOnly={!props.hasPermission}
+ {...fieldProps}
+ />
+ )}
+
+ >
+ )
+}
+
+export type BuildConfigs = {
+ runtimeConfig: PlainMessage
+ staticConfig: PlainMessage
+ buildPackConfig: {
+ context: string
+ }
+ cmdConfig: {
+ baseImage: string
+ buildCmd: string
+ }
+ dockerfileConfig: {
+ dockerfileName: string
+ context: string
+ }
+}
+
+export type BuildConfigForm = {
+ case: PlainMessage['buildConfig']['case']
+ config: BuildConfigs
+}
+
+export const formToConfig = (form: BuildConfigForm): PlainMessage['buildConfig'] => {
+ switch (form.case) {
+ case 'runtimeBuildpack':
+ return {
+ case: 'runtimeBuildpack',
+ value: {
+ runtimeConfig: form.config.runtimeConfig,
+ context: form.config.buildPackConfig.context,
+ },
+ }
+ case 'runtimeCmd':
+ return {
+ case: 'runtimeCmd',
+ value: {
+ runtimeConfig: form.config.runtimeConfig,
+ baseImage: form.config.cmdConfig.baseImage,
+ buildCmd: form.config.cmdConfig.buildCmd,
+ },
+ }
+ case 'runtimeDockerfile':
+ return {
+ case: 'runtimeDockerfile',
+ value: {
+ runtimeConfig: form.config.runtimeConfig,
+ dockerfileName: form.config.dockerfileConfig.dockerfileName,
+ context: form.config.dockerfileConfig.context,
+ },
+ }
+ case 'staticBuildpack':
+ return {
+ case: 'staticBuildpack',
+ value: {
+ staticConfig: form.config.staticConfig,
+ context: form.config.buildPackConfig.context,
+ },
+ }
+ case 'staticCmd':
+ return {
+ case: 'staticCmd',
+ value: {
+ staticConfig: form.config.staticConfig,
+ baseImage: form.config.cmdConfig.baseImage,
+ buildCmd: form.config.cmdConfig.buildCmd,
+ },
+ }
+ case 'staticDockerfile':
+ return {
+ case: 'staticDockerfile',
+ value: {
+ staticConfig: form.config.staticConfig,
+ dockerfileName: form.config.dockerfileConfig.dockerfileName,
+ context: form.config.dockerfileConfig.context,
+ },
+ }
+ }
+ throw new Error('Invalid BuildConfigForm')
+}
+
+const defaultConfigs: BuildConfigs = {
+ buildPackConfig: { context: '' },
+ cmdConfig: { baseImage: '', buildCmd: '' },
+ dockerfileConfig: { context: '', dockerfileName: '' },
+ runtimeConfig: structuredClone(new RuntimeConfig()),
+ staticConfig: structuredClone(new StaticConfig()),
+}
+export const configToForm = (config: PlainMessage | undefined): BuildConfigForm => {
+ switch (config?.buildConfig.case) {
+ case 'runtimeBuildpack':
+ return {
+ case: 'runtimeBuildpack',
+ config: {
+ ...defaultConfigs,
+ runtimeConfig: config.buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig,
+ buildPackConfig: {
+ context: config.buildConfig.value.context,
+ },
+ },
+ }
+ case 'runtimeCmd':
+ return {
+ case: 'runtimeCmd',
+ config: {
+ ...defaultConfigs,
+ runtimeConfig: config.buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig,
+ cmdConfig: {
+ baseImage: config.buildConfig.value.baseImage,
+ buildCmd: config.buildConfig.value.buildCmd,
+ },
+ },
+ }
+ case 'runtimeDockerfile':
+ return {
+ case: 'runtimeDockerfile',
+ config: {
+ ...defaultConfigs,
+ runtimeConfig: config.buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig,
+ dockerfileConfig: {
+ context: config.buildConfig.value.context,
+ dockerfileName: config.buildConfig.value.dockerfileName,
+ },
+ },
+ }
+ case 'staticBuildpack':
+ return {
+ case: 'staticBuildpack',
+ config: {
+ ...defaultConfigs,
+ staticConfig: config.buildConfig.value.staticConfig ?? defaultConfigs.staticConfig,
+ buildPackConfig: {
+ context: config.buildConfig.value.context,
+ },
+ },
+ }
+ case 'staticCmd':
+ return {
+ case: 'staticCmd',
+ config: {
+ ...defaultConfigs,
+ staticConfig: config.buildConfig.value.staticConfig ?? defaultConfigs.staticConfig,
+ cmdConfig: {
+ baseImage: config.buildConfig.value.baseImage,
+ buildCmd: config.buildConfig.value.buildCmd,
+ },
+ },
+ }
+ case 'staticDockerfile':
+ return {
+ case: 'staticDockerfile',
+ config: {
+ ...defaultConfigs,
+ staticConfig: config.buildConfig.value.staticConfig ?? defaultConfigs.staticConfig,
+ dockerfileConfig: {
+ context: config.buildConfig.value.context,
+ dockerfileName: config.buildConfig.value.dockerfileName,
+ },
+ },
+ }
+ default:
+ return {
+ case: undefined,
+ config: defaultConfigs,
+ }
+ }
+}
+
+export interface BuildConfigsProps {
+ formStore: FormStore
+ disableEditDB: boolean
+ hasPermission: boolean
+}
+
+export const BuildConfigs: Component = (props) => {
+ const buildType = () => getValue(props.formStore, 'case')
+
+ return (
+ <>
+
+ {(field, fieldProps) => (
+ setValue(props.formStore, 'case', v)}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/dashboard/src/components/ContainerLog.tsx b/dashboard/src/components/templates/app/ContainerLog.tsx
similarity index 75%
rename from dashboard/src/components/ContainerLog.tsx
rename to dashboard/src/components/templates/app/ContainerLog.tsx
index d6968eb14..5f7e2f81c 100644
--- a/dashboard/src/components/ContainerLog.tsx
+++ b/dashboard/src/components/templates/app/ContainerLog.tsx
@@ -1,14 +1,14 @@
+import { Timestamp } from '@bufbuild/protobuf'
+import { Code, ConnectError } from '@connectrpc/connect'
+import { styled } from '@macaron-css/solid'
+import { Component, For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from 'solid-js'
import { ApplicationOutput } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { LogContainer } from '/@/components/Log'
+import { Button } from '/@/components/UI/Button'
+import { LogContainer } from '/@/components/UI/LogContainer'
import { client, handleAPIError } from '/@/libs/api'
import { toWithAnsi } from '/@/libs/buffers'
import { isScrolledToBottom } from '/@/libs/scroll'
import { addTimestamp, lessTimestamp, minTimestamp } from '/@/libs/timestamp'
-import { vars } from '/@/theme'
-import { Timestamp } from '@bufbuild/protobuf'
-import { Code, ConnectError } from '@connectrpc/connect'
-import { styled } from '@macaron-css/solid'
-import { Component, For, Ref, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from 'solid-js'
const LoadMoreContainer = styled('div', {
base: {
@@ -21,21 +21,6 @@ const LoadMoreContainer = styled('div', {
},
})
-const LoadMoreButton = styled('div', {
- base: {
- background: vars.bg.black1,
- border: `1px solid ${vars.bg.white2}`,
- borderRadius: '2px',
- padding: '2px',
- color: vars.text.white1,
- selectors: {
- '&:hover': {
- background: vars.text.black3,
- },
- },
- },
-})
-
const loadLimitSeconds = 7 * 86400
const loadDuration = 86400n
@@ -45,8 +30,9 @@ const loadLogChunk = async (appID: string, before: Timestamp): Promise
- ts.reduce((acc, t) => minTimestamp(acc, t.time), Timestamp.now())
-const sortByTimestamp = (ts: ApplicationOutput[]) => ts.sort((a, b) => (lessTimestamp(a.time, b.time) ? -1 : 1))
+ ts.reduce((acc, t) => (t.time ? minTimestamp(acc, t.time) : acc), Timestamp.now())
+const sortByTimestamp = (ts: ApplicationOutput[]) =>
+ ts.sort((a, b) => (a.time && b.time ? (lessTimestamp(a.time, b.time) ? -1 : 1) : 0))
export interface ContainerLogProps {
appID: string
@@ -113,19 +99,20 @@ export const ContainerLog: Component = (props) => {
const streamedLogOldest = createMemo(() => {
const logs = streamedLog()
if (logs.length === 0) return
- return logs.reduce((acc, log) => minTimestamp(acc, log.time), Timestamp.now())
+ return logs.reduce((acc, log) => (log.time ? minTimestamp(acc, log.time) : acc), Timestamp.now())
})
createEffect(() => {
- if (!streamedLogOldest()) return
- if (lessTimestamp(streamedLogOldest(), loadedUntil())) {
- setLoadedUntil(streamedLogOldest())
+ const oldest = streamedLogOldest()
+ if (!oldest) return
+ if (lessTimestamp(oldest, loadedUntil())) {
+ setLoadedUntil(oldest)
}
})
- let logRef: Ref
+ let logRef: HTMLDivElement
createEffect(() => {
streamedLog()
- const ref = logRef as HTMLDivElement
+ const ref = logRef
if (!ref) return
if (atBottom()) {
ref.scrollTop = ref.scrollHeight
@@ -136,15 +123,15 @@ export const ContainerLog: Component = (props) => {
const onScroll = (e: { target: Element }) => setAtBottom(isScrolledToBottom(e.target))
return (
-
+
{/* cannot distinguish zero log and loading (but should be enough for most use-cases) */}
0}>
Loaded until {loadedUntil().toDate().toLocaleString()}
(reached load limit)}>
- Loading...}>
- Load more
-
+
@@ -155,5 +142,5 @@ export const ContainerLog: Component = (props) => {
}
const formatLogLine = (log: ApplicationOutput, withTimestamp: boolean): string => {
- return (withTimestamp ? `${log.time.toDate().toLocaleString()} ` : '') + toWithAnsi(log.log)
+ return (withTimestamp ? `${log.time?.toDate().toLocaleString()} ` : '') + toWithAnsi(log.log)
}
diff --git a/dashboard/src/components/templates/app/PortPublications.tsx b/dashboard/src/components/templates/app/PortPublications.tsx
new file mode 100644
index 000000000..063549049
--- /dev/null
+++ b/dashboard/src/components/templates/app/PortPublications.tsx
@@ -0,0 +1,231 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Field, FieldArray, FormStore, custom, getValue, insert, remove, setValue } from '@modular-forms/solid'
+import { For, Show } from 'solid-js'
+import { PortPublication, PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { TextField } from '/@/components/UI/TextField'
+import { systemInfo } from '/@/libs/api'
+import { pickRandom, randIntN } from '/@/libs/random'
+import { colorVars } from '/@/theme'
+import { SelectOption, SingleSelect } from '../Select'
+
+const PortsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '16px',
+ },
+})
+const PortRow = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '24px',
+ },
+})
+const PortVisualContainer = styled('div', {
+ base: {
+ alignItems: 'flex-start',
+ gap: '8px',
+ },
+ variants: {
+ variant: {
+ from: {
+ width: '100%',
+ flexBasis: 'calc(60% - 4px)',
+ display: 'grid',
+ flexGrow: '1',
+ gridTemplateColumns: 'minmax(calc(8ch + 32px), 1fr) auto minmax(calc(4ch + 60px), 1fr)',
+ },
+ to: {
+ width: '100%',
+ flexBasis: 'calc(40% - 4px)',
+ display: 'grid',
+ flexGrow: '1',
+ gridTemplateColumns: 'auto minmax(calc(8ch + 32px), 1fr) auto',
+ },
+ wrapper: {
+ width: '100%',
+ display: 'flex',
+ flexWrap: 'wrap',
+ },
+ },
+ },
+})
+const PortItem = styled('div', {
+ base: {
+ height: '48px',
+ display: 'flex',
+ alignItems: 'center',
+ },
+})
+const textStyle = style({
+ color: colorVars.semantic.text.black,
+})
+
+const protocolItems: SelectOption[] = [
+ { value: PortPublicationProtocol.TCP, label: 'TCP' },
+ { value: PortPublicationProtocol.UDP, label: 'UDP' },
+]
+
+const protoToName: Record = {
+ [PortPublicationProtocol.TCP]: 'TCP',
+ [PortPublicationProtocol.UDP]: 'UDP',
+}
+
+interface PortPublicationProps {
+ formStore: FormStore
+ name: `ports.${number}`
+ deletePort: () => void
+ hasPermission: boolean
+}
+
+const isValidPort = (port?: number, proto?: PortPublicationProtocol): boolean => {
+ if (port === undefined) return false
+ const available = systemInfo()?.ports.filter((a) => a.protocol === proto) || []
+ if (available.length === 0) return false
+ return available.some((range) => port >= range.startPort && port <= range.endPort)
+}
+
+const PortSetting = (props: PortPublicationProps) => {
+ return (
+
+
+
+ isValidPort(port, getValue(props.formStore, `${props.name}.protocol`)),
+ 'Please enter the available port',
+ )}
+ >
+ {(field, fieldProps) => (
+
+ )}
+
+ /
+
+ {(field, fieldProps) => (
+ {
+ setValue(props.formStore, `${props.name}.protocol`, value)
+ }}
+ readOnly={!props.hasPermission}
+ />
+ )}
+
+
+
+ →
+
+ {(field, fieldProps) => (
+
+ )}
+
+
+ {(protocol) => /{protoToName[protocol()]}}
+
+
+
+
+
+
+
+ )
+}
+
+const suggestPort = (proto: PortPublicationProtocol): number => {
+ const available = systemInfo()?.ports.filter((a) => a.protocol === proto) || []
+ if (available.length === 0) return 0
+ const range = pickRandom(available)
+ return randIntN(range.endPort + 1 - range.startPort) + range.startPort
+}
+
+const newPort = (): PlainMessage => {
+ return {
+ internetPort: suggestPort(PortPublicationProtocol.TCP),
+ applicationPort: 0,
+ protocol: PortPublicationProtocol.TCP,
+ }
+}
+
+export type PortSettingsStore = {
+ ports: PlainMessage[]
+}
+interface PortPublicationSettingsProps {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+export const PortPublicationSettings = (props: PortPublicationSettingsProps) => {
+ return (
+
+
+ {(fieldArray) => (
+ ポート公開が設定されていません}>
+ {(_, index) => (
+ remove(props.formStore, 'ports', { at: index() })}
+ hasPermission={props.hasPermission}
+ />
+ )}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/app/SelectBuildType.tsx b/dashboard/src/components/templates/app/SelectBuildType.tsx
new file mode 100644
index 000000000..405c6897f
--- /dev/null
+++ b/dashboard/src/components/templates/app/SelectBuildType.tsx
@@ -0,0 +1,275 @@
+import { RadioGroup } from '@kobalte/core'
+import { style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Component, JSX, Show, createEffect, createSignal, splitProps } from 'solid-js'
+import { RadioIcon } from '/@/components/UI/RadioIcon'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars, media, textVars } from '/@/theme'
+import { FormItem } from '../FormItem'
+import { BuildConfigMethod } from './BuildConfigs'
+
+const ItemsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'stretch',
+ gap: '16px',
+
+ '@media': {
+ [media.mobile]: {
+ flexDirection: 'column',
+ },
+ },
+ },
+})
+const itemStyle = style({
+ width: '100%',
+})
+const labelStyle = style({
+ width: '100%',
+ height: '100%',
+ padding: '16px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ cursor: 'pointer',
+
+ selectors: {
+ '&:hover:not([data-disabled]):not([data-readonly])': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ '&[data-readonly]': {
+ cursor: 'not-allowed',
+ },
+ '&[data-checked]': {
+ outline: `2px solid ${colorVars.semantic.primary.main}`,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: colorVars.semantic.text.disabled,
+ background: colorVars.semantic.ui.tertiary,
+ },
+ '&[data-invalid]': {
+ outline: `2px solid ${colorVars.semantic.accent.error}`,
+ },
+ },
+})
+const ItemTitle = styled('div', {
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '8px',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+ },
+})
+const Description = styled('div', {
+ base: {
+ color: colorVars.semantic.text.black,
+ ...textVars.caption.regular,
+ },
+})
+export const errorTextStyle = style({
+ marginTop: '8px',
+ width: '100%',
+ color: colorVars.semantic.accent.error,
+ ...textVars.text.regular,
+})
+
+const SelectBuildType: Component<{
+ value: BuildConfigMethod | undefined
+ error?: string
+ setValue: (v: BuildConfigMethod | undefined) => void
+ readOnly: boolean
+ ref: (element: HTMLInputElement | HTMLTextAreaElement) => void
+ onInput: JSX.EventHandler
+ onChange: JSX.EventHandler
+ onBlur: JSX.EventHandler
+}> = (props) => {
+ const [inputProps] = splitProps(props, ['ref', 'onInput', 'onChange', 'onBlur'])
+ const [runType, setRunType] = createSignal<'runtime' | 'static' | undefined>()
+ const [buildType, setBuildType] = createSignal<'buildpack' | 'cmd' | 'dockerfile' | undefined>()
+
+ createEffect(() => {
+ switch (props.value) {
+ case 'runtimeBuildpack':
+ case 'runtimeCmd':
+ case 'runtimeDockerfile':
+ setRunType('runtime')
+ break
+ case 'staticBuildpack':
+ case 'staticCmd':
+ case 'staticDockerfile':
+ setRunType('static')
+ break
+ }
+
+ switch (props.value) {
+ case 'runtimeBuildpack':
+ case 'staticBuildpack':
+ setBuildType('buildpack')
+ break
+ case 'runtimeCmd':
+ case 'staticCmd':
+ setBuildType('cmd')
+ break
+ case 'runtimeDockerfile':
+ case 'staticDockerfile':
+ setBuildType('dockerfile')
+ break
+ case undefined:
+ setBuildType(undefined)
+ break
+ }
+ })
+
+ createEffect(() => {
+ const _runType = runType()
+ const _buildType = buildType()
+ if (_runType === undefined || _buildType === undefined) {
+ props.setValue(undefined)
+ return
+ }
+
+ switch (_runType) {
+ case 'runtime':
+ switch (_buildType) {
+ case 'buildpack':
+ props.setValue('runtimeBuildpack')
+ break
+ case 'cmd':
+ props.setValue('runtimeCmd')
+ break
+ case 'dockerfile':
+ props.setValue('runtimeDockerfile')
+ break
+ }
+ break
+ case 'static':
+ switch (_buildType) {
+ case 'buildpack':
+ props.setValue('staticBuildpack')
+ break
+ case 'cmd':
+ props.setValue('staticCmd')
+ break
+ case 'dockerfile':
+ props.setValue('staticDockerfile')
+ break
+ }
+ break
+ }
+ })
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Runtime
+
+
+
+
+
+
+
+ コマンドを実行してアプリを起動します。サーバープロセスやバックグラウンド処理がある場合、こちらを選びます。
+
+
+
+
+
+
+
+ Static
+
+
+
+
+
+
+ 静的ファイルを配信します。ビルド(任意)を実行できます。
+
+
+
+
+ {props.error && buildType() === undefined ? 'Select Application Type' : ''}
+
+
+
+
+
+
+
+
+
+
+
+ Buildpack
+
+
+
+
+
+
+ ビルド設定を、リポジトリ内ファイルから自動検出します。(オススメ)
+
+
+
+
+
+
+ Command
+
+
+
+
+
+
+ ベースイメージとビルドコマンド(任意)を設定します。
+
+
+
+
+
+
+ Dockerfile
+
+
+
+
+
+
+ リポジトリ内Dockerfileからビルドを行います。
+
+
+
+ {props.error}
+
+
+
+ >
+ )
+}
+
+export default SelectBuildType
diff --git a/dashboard/src/components/templates/app/WebsiteSettings.tsx b/dashboard/src/components/templates/app/WebsiteSettings.tsx
new file mode 100644
index 000000000..4756dffe0
--- /dev/null
+++ b/dashboard/src/components/templates/app/WebsiteSettings.tsx
@@ -0,0 +1,575 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { styled } from '@macaron-css/solid'
+import { Field, Form, FormStore, getValue, reset, setValue, toCustom } from '@modular-forms/solid'
+import { For, Show, createEffect, createMemo, createReaction, on, onMount } from 'solid-js'
+import {
+ AuthenticationType,
+ AvailableDomain,
+ CreateWebsiteRequest,
+ Website,
+} from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm'
+import { TextField } from '/@/components/UI/TextField'
+import FormBox from '/@/components/layouts/FormBox'
+import { systemInfo } from '/@/libs/api'
+import useModal from '/@/libs/useModal'
+import { CheckBox } from '../CheckBox'
+import { FormItem } from '../FormItem'
+import { List } from '../List'
+import { RadioGroup, RadioOption } from '../RadioGroups'
+import { SelectOption, SingleSelect } from '../Select'
+
+const URLContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'flex-top',
+ gap: '8px',
+ },
+})
+const URLItem = styled('div', {
+ base: {
+ height: '48px',
+ display: 'flex',
+ alignItems: 'center',
+ },
+})
+const HttpSelectContainer = styled('div', {
+ base: {
+ flexShrink: 0,
+ width: 'calc(6ch + 60px)',
+ },
+})
+const DeleteButtonContainer = styled('div', {
+ base: {
+ width: 'fit-content',
+ marginRight: 'auto',
+ },
+})
+const AddMoreButtonContainer = styled('div', {
+ base: {
+ display: 'flex',
+ justifyContent: 'center',
+ },
+})
+
+interface WebsiteSettingProps {
+ isRuntimeApp: boolean
+ formStore: FormStore
+ saveWebsite?: () => void
+ deleteWebsite: () => void
+ hasPermission: boolean
+}
+
+const schemeOptions: SelectOption<`${boolean}`>[] = [
+ { value: 'false', label: 'http' },
+ { value: 'true', label: 'https' },
+]
+
+const authenticationTypeOptionsMap = {
+ [`${AuthenticationType.OFF}`]: AuthenticationType.OFF,
+ [`${AuthenticationType.SOFT}`]: AuthenticationType.SOFT,
+ [`${AuthenticationType.HARD}`]: AuthenticationType.HARD,
+}
+
+const authenticationTypeOptions: RadioOption<`${AuthenticationType}`>[] = [
+ { value: `${AuthenticationType.OFF}`, label: 'OFF' },
+ { value: `${AuthenticationType.SOFT}`, label: 'SOFT' },
+ { value: `${AuthenticationType.HARD}`, label: 'HARD' },
+]
+
+export const WebsiteSetting = (props: WebsiteSettingProps) => {
+ const state = () => getValue(props.formStore, 'state')
+ const discardChanges = () => reset(props.formStore)
+
+ const { Modal, open, close } = useModal()
+
+ const nonWildcardDomains = createMemo(() => systemInfo()?.domains.filter((d) => !d.domain.startsWith('*')) ?? [])
+ const wildCardDomains = createMemo(() => systemInfo()?.domains.filter((d) => d.domain.startsWith('*')) ?? [])
+ const websiteUrl = () => {
+ const scheme = getValue(props.formStore, 'website.https') ? 'https' : 'http'
+ const fqdn = getValue(props.formStore, 'website.fqdn')
+ const pathPrefix = getValue(props.formStore, 'website.pathPrefix')
+ return `${scheme}://${fqdn}${pathPrefix}`
+ }
+
+ const extractHost = (
+ fqdn: string,
+ ): {
+ host: string
+ domain: PlainMessage
+ } => {
+ const matchNonWildcardDomain = nonWildcardDomains().find((d) => fqdn === d.domain)
+ if (matchNonWildcardDomain !== undefined) {
+ return {
+ host: '',
+ domain: matchNonWildcardDomain,
+ }
+ }
+
+ const matchDomain = wildCardDomains().find((d) => fqdn?.endsWith(d.domain.replace(/\*/g, '')))
+ if (matchDomain === undefined) {
+ const fallbackDomain = systemInfo()?.domains[0]
+ if (fallbackDomain === undefined) throw new Error('No domain available')
+ return {
+ host: '',
+ domain: fallbackDomain,
+ }
+ }
+ return {
+ host: fqdn.slice(0, -matchDomain.domain.length + 1),
+ domain: matchDomain,
+ }
+ }
+
+ // set host and domain from fqdn on fqdn change
+ createEffect(
+ on(
+ () => getValue(props.formStore, 'website.fqdn'),
+ (fqdn) => {
+ if (fqdn === undefined) return
+ const { host, domain } = extractHost(fqdn)
+ setValue(props.formStore, 'website.host', host)
+ setValue(props.formStore, 'website.domain', domain.domain)
+ setValue(props.formStore, 'website.authAvailable', domain.authAvailable)
+ if (domain.authAvailable === false) {
+ setValue(props.formStore, 'website.authentication', AuthenticationType.OFF)
+ }
+ },
+ ),
+ )
+
+ const resetHostAndDomain = createReaction(() => {
+ const fqdn = getValue(props.formStore, 'website.fqdn')
+ if (fqdn === undefined) return
+ const { host, domain } = extractHost(fqdn)
+ reset(props.formStore, 'website.host', {
+ initialValue: host,
+ })
+ reset(props.formStore, 'website.domain', {
+ initialValue: domain.domain,
+ })
+ reset(props.formStore, 'website.authAvailable', {
+ initialValue: domain.authAvailable,
+ })
+ })
+
+ onMount(() => {
+ // Reset host and domain on first fqdn change
+ resetHostAndDomain(() => getValue(props.formStore, 'website.fqdn'))
+ })
+
+ // set fqdn from host and domain on host or domain change
+ createEffect(
+ on(
+ [() => getValue(props.formStore, 'website.host'), () => getValue(props.formStore, 'website.domain')],
+ ([host, domain]) => {
+ if (host === undefined || domain === undefined) return
+ if (domain.startsWith('*')) {
+ // wildcard domainならhostとdomainを結合
+ const fqdn = `${host}${domain?.replace(/\*/g, '')}`
+ setValue(props.formStore, 'website.fqdn', fqdn)
+ } else {
+ // non-wildcard domainならdomainをそのまま使う
+ setValue(props.formStore, 'website.fqdn', domain)
+ }
+ },
+ ),
+ )
+
+ return (
+
+ )
+}
+
+type FQDN = {
+ host: string
+ domain: PlainMessage['domain']
+ authAvailable: PlainMessage['authAvailable']
+}
+
+export type WebsiteSetting =
+ | {
+ /**
+ * - `noChange`: 既存の設定を変更していない
+ * - `readyToChange`: 次の保存時に変更を反映する
+ * - `readyToDelete`: 次の保存時に削除する
+ */
+ state: 'noChange' | 'readyToChange' | 'readyToDelete'
+ website: PlainMessage & FQDN
+ }
+ | {
+ /**
+ * - `added`: 新規に設定を追加した
+ */
+ state: 'added'
+ website: PlainMessage & FQDN
+ }
+
+export type WebsiteSettingForm = {
+ websites: WebsiteSetting[]
+}
+
+export const newWebsite = (): PlainMessage => ({
+ fqdn: '',
+ pathPrefix: '/',
+ stripPrefix: false,
+ https: true,
+ h2c: false,
+ httpPort: 0,
+ authentication: AuthenticationType.OFF,
+})
+
+interface WebsiteSettingsProps {
+ isRuntimeApp: boolean
+ formStores: FormStore[]
+ addWebsite: () => void
+ deleteWebsiteForm: (index: number) => void
+ applyChanges: () => void
+ hasPermission: boolean
+}
+
+export const WebsiteSettings = (props: WebsiteSettingsProps) => {
+ return (
+
+
+
+ link_off
+ URLが設定されていません
+
+
+
+
+
+ }
+ >
+ {(form, index) => (
+ {
+ if (getValue(props.formStores[index()], 'state') === 'noChange') {
+ setValue(props.formStores[index()], 'state', 'readyToChange')
+ }
+ props.applyChanges()
+ }}
+ deleteWebsite={() => {
+ props.deleteWebsiteForm(index())
+ }}
+ hasPermission={props.hasPermission}
+ />
+ )}
+
+ 0 && props.hasPermission}>
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/build/ArtifactRow.tsx b/dashboard/src/components/templates/build/ArtifactRow.tsx
new file mode 100644
index 000000000..494d47356
--- /dev/null
+++ b/dashboard/src/components/templates/build/ArtifactRow.tsx
@@ -0,0 +1,100 @@
+import { styled } from '@macaron-css/solid'
+import { Component } from 'solid-js'
+import { Artifact } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { client, handleAPIError } from '/@/libs/api'
+import { formatBytes } from '/@/libs/format'
+import { colorVars, textVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 16px 16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+ background: colorVars.semantic.ui.primary,
+ },
+})
+const ContentsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ },
+})
+const TitleContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ },
+})
+const ArtifactName = styled('div', {
+ base: {
+ width: '100%',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.regular,
+ },
+})
+const MetaContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const ArtifactSize = styled('div', {
+ base: {
+ width: 'fit-content',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+})
+export interface Props {
+ artifact: Artifact
+}
+
+const downloadArtifact = async (id: string) => {
+ try {
+ const data = await client.getBuildArtifact({ artifactId: id })
+ const dataBlob = new Blob([data.content], { type: 'application/gzip' })
+ const blobUrl = URL.createObjectURL(dataBlob)
+ const anchor = document.createElement('a')
+ anchor.href = blobUrl
+ anchor.download = data.filename
+ anchor.click()
+ URL.revokeObjectURL(blobUrl)
+ } catch (e) {
+ handleAPIError(e, '成果物のダウンロードに失敗しました')
+ }
+}
+
+export const ArtifactRow: Component = (props) => {
+ return (
+
+
+
+ {props.artifact.name}
+
+
+ {formatBytes(+props.artifact.size.toString())}
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/BuildLog.tsx b/dashboard/src/components/templates/build/BuildLog.tsx
similarity index 86%
rename from dashboard/src/components/BuildLog.tsx
rename to dashboard/src/components/templates/build/BuildLog.tsx
index 92beb4f83..ebfaba184 100644
--- a/dashboard/src/components/BuildLog.tsx
+++ b/dashboard/src/components/templates/build/BuildLog.tsx
@@ -1,10 +1,10 @@
-import { LogContainer } from '/@/components/Log'
+import { Code, ConnectError } from '@connectrpc/connect'
+import { Component, Show, createEffect, createResource, createSignal, onCleanup } from 'solid-js'
+import { LogContainer } from '/@/components/UI/LogContainer'
import { client } from '/@/libs/api'
import { concatBuffers, toUTF8WithAnsi } from '/@/libs/buffers'
import { isScrolledToBottom } from '/@/libs/scroll'
import { sleep } from '/@/libs/sleep'
-import { Code, ConnectError } from '@connectrpc/connect'
-import { Component, Ref, Show, createEffect, createResource, createSignal, onCleanup } from 'solid-js'
export interface BuildLogProps {
buildID: string
@@ -49,8 +49,8 @@ export const BuildLog: Component = (props) => {
logStreamAbort.abort()
})
- let logRef: Ref
- let streamLogRef: Ref
+ let logRef: HTMLDivElement
+ let streamLogRef: HTMLDivElement
createEffect(() => {
if (!buildLog()) return
const ref = logRef as HTMLDivElement
@@ -74,10 +74,10 @@ export const BuildLog: Component = (props) => {
return (
<>
-
+
-
+
>
)
diff --git a/dashboard/src/components/templates/build/BuildRow.tsx b/dashboard/src/components/templates/build/BuildRow.tsx
new file mode 100644
index 000000000..46923e72b
--- /dev/null
+++ b/dashboard/src/components/templates/build/BuildRow.tsx
@@ -0,0 +1,115 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import { Build } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import Badge from '/@/components/UI/Badge'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { diffHuman, shortSha } from '/@/libs/format'
+import { colorVars, textVars } from '/@/theme'
+import { BuildStatusIcon } from './BuildStatusIcon'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 16px 16px 20px',
+ cursor: 'pointer',
+ background: colorVars.semantic.ui.primary,
+
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.primitive.blackAlpha[50]),
+ },
+ },
+ },
+})
+const TitleContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ },
+})
+const BuildName = styled('div', {
+ base: {
+ width: 'auto',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.regular,
+ },
+})
+const Spacer = styled('div', {
+ base: {
+ flexGrow: 1,
+ },
+})
+const UpdatedAt = styled('div', {
+ base: {
+ flexShrink: 0,
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const MetaContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ padding: '0 0 0 32px',
+
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const AppName = styled('div', {
+ base: {
+ width: 'fit-content',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+})
+
+export interface Props {
+ build: Build
+ appName?: string
+ isCurrent: boolean
+}
+
+export const BuildRow: Component = (props) => {
+ return (
+
+
+
+
+ Build at {shortSha(props.build.commit)}
+
+
+ Current
+
+
+
+
+
+
+ {props.appName}・
+
+
+ {(nonNullQueuedAt) => {
+ const { diff, localeString } = diffHuman(nonNullQueuedAt().toDate())
+ return (
+
+ {diff}
+
+ )
+ }}
+
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/build/BuildStatusIcon.tsx b/dashboard/src/components/templates/build/BuildStatusIcon.tsx
new file mode 100644
index 000000000..93657b100
--- /dev/null
+++ b/dashboard/src/components/templates/build/BuildStatusIcon.tsx
@@ -0,0 +1,60 @@
+import { JSXElement } from 'solid-js'
+import { Dynamic } from 'solid-js/web'
+import { BuildStatus } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { buildStatusStr } from '/@/libs/application'
+import { colorVars } from '/@/theme'
+
+interface IconProps {
+ size: number
+}
+const components: Record JSXElement> = {
+ [BuildStatus.QUEUED]: (props) => (
+
+ do_not_disturb_on
+
+ ),
+ [BuildStatus.BUILDING]: (props) => (
+
+ offline_bolt
+
+ ),
+ [BuildStatus.SUCCEEDED]: (props) => (
+
+ check_circle
+
+ ),
+ [BuildStatus.FAILED]: (props) => (
+
+ error
+
+ ),
+ [BuildStatus.CANCELLED]: (props) => (
+
+ do_not_disturb_on
+
+ ),
+ [BuildStatus.SKIPPED]: (props) => (
+
+ do_not_disturb_on
+
+ ),
+}
+
+interface Props {
+ state: BuildStatus
+ size?: number
+}
+
+export const BuildStatusIcon = (props: Props): JSXElement => {
+ return (
+
+
+
+ )
+}
diff --git a/dashboard/src/components/templates/build/BuildStatusTable.tsx b/dashboard/src/components/templates/build/BuildStatusTable.tsx
new file mode 100644
index 000000000..d47772f00
--- /dev/null
+++ b/dashboard/src/components/templates/build/BuildStatusTable.tsx
@@ -0,0 +1,175 @@
+import { Timestamp } from '@bufbuild/protobuf'
+import { styled } from '@macaron-css/solid'
+import { useNavigate } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import toast from 'solid-toast'
+import { Application, Build, BuildStatus, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { ToolTip } from '/@/components/UI/ToolTip'
+import { client, handleAPIError } from '/@/libs/api'
+import { buildStatusStr } from '/@/libs/application'
+import { diffHuman, durationHuman, shortSha } from '/@/libs/format'
+import { colorVars, textVars } from '/@/theme'
+import { List } from '../List'
+import { BuildStatusIcon } from './BuildStatusIcon'
+
+const BuildStatusRow = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.secondary,
+ },
+})
+const BuildStatusLabel = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '4px',
+
+ color: colorVars.semantic.text.black,
+ ...textVars.text.medium,
+ },
+})
+
+const BuildStatusTable: Component<{
+ app: Application
+ repo: Repository
+ build: Build
+ refetchBuild: () => Promise
+ hasPermission: boolean
+}> = (props) => {
+ const navigate = useNavigate()
+
+ const rebuild = async () => {
+ try {
+ await client.retryCommitBuild({
+ applicationId: props.app.id,
+ commit: props.build.commit,
+ })
+ await props.refetchBuild()
+ toast.success('再ビルドを開始しました')
+ // 非同期でビルドが開始されるので1秒程度待ってから遷移
+ setTimeout(() => navigate(`/apps/${props.app.id}`), 1000)
+ } catch (e) {
+ handleAPIError(e, '再ビルドに失敗しました')
+ }
+ }
+ const cancelBuild = async () => {
+ try {
+ await client.cancelBuild({
+ buildId: props.build?.id,
+ })
+ await props.refetchBuild()
+ toast.success('ビルドをキャンセルしました')
+ } catch (e) {
+ handleAPIError(e, 'ビルドのキャンセルに失敗しました')
+ }
+ }
+
+ return (
+
+
+
+
+ {buildStatusStr[props.build.status]}
+
+
+
+
+
+
+
+
+
+
+ Source Commit
+ {shortSha(props.build.commit)}
+
+
+
+
+ {(nonNullQueuedAt) => {
+ const { diff, localeString } = diffHuman(nonNullQueuedAt().toDate())
+ return (
+
+
+ キュー登録時刻
+
+ {diff}
+
+
+
+ )
+ }}
+
+
+ {(nonNullStartedAt) => {
+ const { diff, localeString } = diffHuman((nonNullStartedAt().timestamp as Timestamp).toDate())
+ return (
+
+
+ ビルド開始時刻
+
+ {diff}
+
+
+
+ )
+ }}
+
+
+
+
+
+ ビルド終了時刻
+
+ {(nonNullFinishedAt) => {
+ const { diff, localeString } = diffHuman((nonNullFinishedAt().timestamp as Timestamp).toDate())
+ return (
+
+ {diff}
+
+ )
+ }}
+
+
+
+
+
+ ビルド時間
+
+
+ {durationHuman(
+ props.build.finishedAt!.timestamp!.toDate().getTime() -
+ props.build.startedAt!.timestamp!.toDate().getTime(),
+ )}
+
+
+
+
+
+
+ )
+}
+
+export default BuildStatusTable
diff --git a/dashboard/src/components/templates/repo/ReposFilter.tsx b/dashboard/src/components/templates/repo/ReposFilter.tsx
new file mode 100644
index 000000000..39d81533e
--- /dev/null
+++ b/dashboard/src/components/templates/repo/ReposFilter.tsx
@@ -0,0 +1,190 @@
+import { Checkbox, DropdownMenu } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
+import { styled } from '@macaron-css/solid'
+import { Component, For, Setter, Show } from 'solid-js'
+import { CheckBoxIcon } from '/@/components/UI/CheckBoxIcon'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { Provider, providerToIcon } from '/@/libs/application'
+import { allProviders } from '/@/pages/apps'
+import { colorVars, textVars } from '/@/theme'
+
+const contentShowKeyframes = keyframes({
+ from: { opacity: 0, transform: 'translateY(-8px)' },
+ to: { opacity: 1, transform: 'translateY(0)' },
+})
+const contentHideKeyframes = keyframes({
+ from: { opacity: 1, transform: 'translateY(0)' },
+ to: { opacity: 0, transform: 'translateY(-8px)' },
+})
+const contentStyle = style({
+ padding: '16px',
+ display: 'flex',
+ gap: '8px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '6px',
+ boxShadow: '0px 0px 20px 0px rgba(0, 0, 0, 0.10)',
+ zIndex: 1,
+
+ transformOrigin: 'var(--kb-menu-content-transform-origin)',
+ animation: `${contentHideKeyframes} 0.2s ease-in-out`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${contentShowKeyframes} 0.2s ease-in-out`,
+ },
+ },
+})
+const indicatorStyle = style({
+ width: '24px',
+ height: '24px',
+})
+const ItemsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+})
+const SelectItemStyle = style({
+ width: '100%',
+ height: '44px',
+ padding: '8px',
+ display: 'flex',
+ flexWrap: 'nowrap',
+ alignItems: 'center',
+ gap: '8px',
+
+ background: 'none',
+ border: 'none',
+ borderRadius: '8px',
+ cursor: 'pointer',
+ color: colorVars.semantic.text.black,
+ whiteSpace: 'nowrap',
+ ...textVars.text.bold,
+
+ selectors: {
+ '&:hover, &[data-highlighted]': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&[data-disabled]': {
+ cursor: 'not-allowed',
+ color: `${colorVars.semantic.text.black} !important`,
+ background: `${colorVars.semantic.text.disabled} !important`,
+ },
+ },
+})
+const FilterItemContainer = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+ },
+})
+const FilterButton = style({
+ padding: '8px',
+ display: 'flex',
+ background: 'none',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+})
+const IconContainer = styled('div', {
+ base: {
+ position: 'relative',
+ width: '24px',
+ height: '24px',
+ },
+})
+const iconStyle = style({
+ width: '24px',
+ height: '24px',
+ transition: 'transform 0.2s',
+ selectors: {
+ '&[data-expanded]': {
+ transform: 'rotate(180deg)',
+ },
+ },
+})
+const FilterIndicator = styled('div', {
+ base: {
+ position: 'absolute',
+ width: '8px',
+ height: '8px',
+ right: '-2px',
+ top: '-2px',
+ borderRadius: '4px',
+ background: colorVars.semantic.primary.main,
+ outline: `1px solid ${colorVars.semantic.ui.background}`,
+ },
+})
+
+const ReposFilter: Component<{
+ provider: Provider[]
+ setProvider: Setter
+}> = (props) => {
+ const filtered = () => props.provider.length !== allProviders.length
+
+ return (
+
+
+
+ tune
+
+
+
+
+
+ expand_more
+
+
+
+
+
+ Provider
+
+
+ {(s) => (
+ {
+ if (selected) {
+ props.setProvider([...props.provider, s.value])
+ } else {
+ props.setProvider(props.provider.filter((v) => v !== s.value))
+ }
+ }}
+ >
+
+
+
+
+
+ {providerToIcon(s.value)}
+ {s.label}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+export default ReposFilter
diff --git a/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx b/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx
new file mode 100644
index 000000000..bd803ab8e
--- /dev/null
+++ b/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx
@@ -0,0 +1,261 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { styled } from '@macaron-css/solid'
+import { Field, FormStore, ValidateField, getValue, required, setValue } from '@modular-forms/solid'
+import { Match, Show, Switch, createEffect, createResource, createSignal } from 'solid-js'
+import { Suspense } from 'solid-js'
+import {
+ CreateRepositoryAuth,
+ CreateRepositoryRequest,
+ UpdateRepositoryRequest,
+} from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { TextField } from '/@/components/UI/TextField'
+import { client, systemInfo } from '/@/libs/api'
+import { colorVars, textVars } from '/@/theme'
+import { TooltipInfoIcon } from '../../UI/TooltipInfoIcon'
+import { FormItem } from '../FormItem'
+import { RadioGroup, RadioOption } from '../RadioGroups'
+
+const SshKeyContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const RefreshButtonContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: '8px',
+
+ color: colorVars.semantic.accent.error,
+ ...textVars.caption.regular,
+ },
+})
+const VisibilityButton = styled('button', {
+ base: {
+ width: '40px',
+ height: '40px',
+ padding: '8px',
+ background: 'none',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+ },
+})
+
+const AuthMethods: RadioOption>[] = [
+ { label: '認証を使用しない', value: 'none' },
+ { label: 'BASIC認証', value: 'basic' },
+ { label: 'SSH公開鍵認証', value: 'ssh' },
+]
+
+type AuthMethods = {
+ [K in Exclude['auth']['case'], undefined>]: Extract<
+ PlainMessage['auth'],
+ { case: K }
+ >['value']
+}
+
+export type AuthForm = {
+ url: PlainMessage['url']
+ case: PlainMessage['auth']['case']
+ auth: AuthMethods
+}
+
+export const formToAuth = (form: T): PlainMessage['auth'] => {
+ const authMethod = form.case
+ switch (authMethod) {
+ case 'none':
+ return {
+ case: 'none',
+ value: '',
+ }
+ case 'basic':
+ return {
+ case: 'basic',
+ value: {
+ username: form.auth.basic.username,
+ password: form.auth.basic.password,
+ },
+ }
+ case 'ssh':
+ return {
+ case: 'ssh',
+ value: {
+ keyId: form.auth.ssh.keyId,
+ },
+ }
+ }
+ throw new Error('unreachable')
+}
+
+interface Props {
+ formStore: FormStore
+ hasPermission: boolean
+}
+
+export const RepositoryAuthSettings = (props: Props) => {
+ const [showPassword, setShowPassword] = createSignal(false)
+ const [useTmpKey, setUseTmpKey] = createSignal(false)
+ const [tmpKey] = createResource(
+ () => (useTmpKey() ? true : undefined),
+ () => client.generateKeyPair({}),
+ )
+ createEffect(() => {
+ if (tmpKey.latest !== undefined) {
+ setValue(props.formStore, 'auth.ssh.keyId', tmpKey().keyId)
+ }
+ })
+ const publicKey = () => (useTmpKey() ? tmpKey()?.publicKey : systemInfo()?.publicKey ?? '')
+
+ const AuthMethod = () => (
+
+ {(field, fieldProps) => (
+
+ )}
+
+ )
+
+ const validateUrl: ValidateField = (url) => {
+ if (getValue(props.formStore, 'case') === 'basic' && !url?.startsWith('https')) {
+ return 'Basic認証を使用する場合、URLはhttps://から始まる必要があります'
+ }
+ return ''
+ }
+ const Url = () => {
+ return (
+
+ {(field, fieldProps) => (
+
+ )}
+
+ )
+ }
+
+ const AuthConfig = () => {
+ const authMethod = () => getValue(props.formStore, 'case')
+ return (
+
+
+
+ {(field, fieldProps) => (
+
+ )}
+
+
+ {(field, fieldProps) => (
+ setShowPassword((s) => !s)} type="button">
+ visibility_off}>
+ visibility
+
+
+ }
+ />
+ )}
+
+
+
+
+ {() => (
+
+
+
+ 以下のSSH公開鍵{useTmpKey() ? '(このリポジトリ専用)' : '(NeoShowcase全体共通)'}
+ を、リポジトリのデプロイキーとして登録してください。
+
+ 公開リポジトリの場合は、この操作は不要です。
+
+
+
+
+
+ このリポジトリ専用のSSH用鍵ペアを生成します。
+
+ NeoShowcase全体で共通の公開鍵が、リポジトリに登録できない場合に生成してください。
+
+ GitHubプライベートリポジトリの場合は必ず生成が必要です。
+ >
+ ),
+ }}
+ style="left"
+ />
+
+
+
+
+
+ )}
+
+
+
+ )
+ }
+
+ return {
+ AuthMethod,
+ Url,
+ AuthConfig,
+ }
+}
diff --git a/dashboard/src/components/templates/repo/RepositoryNav.tsx b/dashboard/src/components/templates/repo/RepositoryNav.tsx
new file mode 100644
index 000000000..3dfdfab20
--- /dev/null
+++ b/dashboard/src/components/templates/repo/RepositoryNav.tsx
@@ -0,0 +1,12 @@
+import { Component } from 'solid-js'
+import { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
+import { Nav } from '../Nav'
+
+export interface Props {
+ repository: Repository
+}
+
+export const RepositoryNav: Component = (props) => {
+ return
+}
diff --git a/dashboard/src/components/templates/repo/RepositoryRow.tsx b/dashboard/src/components/templates/repo/RepositoryRow.tsx
new file mode 100644
index 000000000..f39ee9df4
--- /dev/null
+++ b/dashboard/src/components/templates/repo/RepositoryRow.tsx
@@ -0,0 +1,108 @@
+import { styled } from '@macaron-css/solid'
+import { A } from '@solidjs/router'
+import { Component, Show } from 'solid-js'
+import { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import Skeleton from '/@/components/UI/Skeleton'
+import { user } from '/@/libs/api'
+import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
+import { colorVars, textVars } from '/@/theme'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '76px',
+ padding: '16px 16px 16px 20px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '32px',
+
+ background: colorVars.semantic.ui.primary,
+ },
+})
+const TitleContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ overflow: 'hidden',
+ },
+})
+const RepositoryName = styled('div', {
+ base: {
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.bold,
+ },
+})
+const AppCount = styled('div', {
+ base: {
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const AddNewAppButtonContainer = styled('div', {
+ base: {
+ flexShrink: 0,
+ },
+})
+
+const RepositoryRowSkeleton: Component = () => {
+ return (
+
+
+
+ Repository Name Placeholder
+
+
+ )
+}
+
+export interface Props {
+ repository?: Repository
+ appCount?: number
+}
+
+export const RepositoryRow: Component = (props) => {
+ const canEdit = () => user()?.admin || (user.latest !== undefined && props.repository?.ownerIds.includes(user()?.id))
+
+ return (
+ }>
+
+
+ {providerToIcon(repositoryURLToProvider(props.repository!.url), 24)}
+
+ {props.repository!.name}
+
+ {`${props.appCount} apps`}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/global-style.ts b/dashboard/src/global-style.ts
index 447173138..be8b1e28a 100644
--- a/dashboard/src/global-style.ts
+++ b/dashboard/src/global-style.ts
@@ -1,42 +1,59 @@
-import { vars } from '/@/theme'
import { globalStyle } from '@macaron-css/core'
-import { TippyOptions } from 'solid-tippy'
-import 'tippy.js/animations/shift-away-subtle.css'
-import 'tippy.js/dist/tippy.css'
+import { colorVars } from '/@/theme'
+import './reset.css'
-declare module 'solid-js' {
- namespace JSX {
- interface Directives {
- tippy: TippyOptions
- }
- }
-}
+globalStyle('*, ::before, ::after', {
+ margin: 0,
+ padding: 0,
+})
-globalStyle('*', {
- boxSizing: 'border-box',
+globalStyle('body', {
+ fontFamily: 'Lato, sans-serif',
+ backgroundColor: colorVars.semantic.ui.primary,
})
-globalStyle('div, h1, h2, h3, h4, h5, h6, a, p, input, select, textarea', {
- fontFamily: 'Noto Sans JP',
+globalStyle('#root', {
+ position: 'fixed',
+ inset: 0,
})
globalStyle('pre, code', {
- fontFamily: 'Menlo, Monaco, Consolas, Courier New, monospace !important',
+ fontFamily: 'Menlo, Monaco, Consolas, Courier New, monospace',
})
globalStyle('a', {
+ color: colorVars.semantic.text.link,
textDecoration: 'none',
-})
-
-globalStyle('pre', {
- margin: 0,
+ overflowWrap: 'anywhere',
})
globalStyle('svg', {
fill: 'currentcolor',
})
-globalStyle('body', {
- margin: '0',
- backgroundColor: vars.bg.white2,
+// Scrollbar
+globalStyle('*', {
+ scrollbarColor: `${colorVars.semantic.ui.tertiary} ${colorVars.semantic.ui.secondary}`,
+ scrollbarWidth: 'thin',
+ transition: 'scrollbar-color 0.3s',
+})
+globalStyle('*:hover, *:active', {
+ scrollbarColor: `${colorVars.semantic.ui.border} ${colorVars.semantic.ui.secondary}`,
+})
+globalStyle('*::-webkit-scrollbar', {
+ width: '6px',
+ height: '6px',
+})
+globalStyle('*::-webkit-scrollbar-corner', {
+ visibility: 'hidden',
+ display: 'none',
+})
+globalStyle('*::-webkit-scrollbar-thumb', {
+ background: colorVars.semantic.ui.tertiary,
+})
+globalStyle('*::-webkit-scrollbar-thumb:hover', {
+ background: colorVars.semantic.ui.border,
+})
+globalStyle('*::-webkit-scrollbar-track', {
+ background: colorVars.semantic.ui.secondary,
})
diff --git a/dashboard/src/index.tsx b/dashboard/src/index.tsx
index 6168d3023..40f9894b1 100644
--- a/dashboard/src/index.tsx
+++ b/dashboard/src/index.tsx
@@ -1,6 +1,6 @@
/* @refresh reload */
import { render } from 'solid-js/web'
-import { TippyOptions } from 'solid-tippy'
+
import App from './App'
import './global-style'
diff --git a/dashboard/src/libs/api.ts b/dashboard/src/libs/api.ts
index 2124ef0fc..5170044c4 100644
--- a/dashboard/src/libs/api.ts
+++ b/dashboard/src/libs/api.ts
@@ -1,8 +1,8 @@
-import { APIService } from '/@/api/neoshowcase/protobuf/gateway_connect'
import { createPromiseClient } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-web'
import { createResource } from 'solid-js'
import toast from 'solid-toast'
+import { APIService } from '/@/api/neoshowcase/protobuf/gateway_connect'
const transport = createConnectTransport({
baseUrl: '',
@@ -13,8 +13,8 @@ export const [user] = createResource(() => client.getMe({}))
export const [systemInfo] = createResource(() => client.getSystemInfo({}))
export const [availableMetrics] = createResource(() => client.getAvailableMetrics({}))
-export const handleAPIError = (e, message: string) => {
- if (e.message) {
+export const handleAPIError = (e: unknown, message: string) => {
+ if (e instanceof Error) {
//' e instanceof ConnectError' does not work for some reason
toast.error(`${message}\n${e.message}`)
} else {
diff --git a/dashboard/src/libs/application.tsx b/dashboard/src/libs/application.tsx
index a9c644c5b..d33dff117 100644
--- a/dashboard/src/libs/application.tsx
+++ b/dashboard/src/libs/application.tsx
@@ -1,26 +1,17 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { AiFillGithub, AiFillGitlab } from 'solid-icons/ai'
+import { SiGitea } from 'solid-icons/si'
+import { JSXElement } from 'solid-js'
import {
Application,
Application_ContainerState,
BuildStatus,
+ CreateWebsiteRequest,
DeployType,
PortPublicationProtocol,
Website,
} from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { Provider } from '/@/components/RepositoryRow'
-import { vars } from '/@/theme'
-import { AiFillGithub, AiFillGitlab } from 'solid-icons/ai'
-import { SiGitea } from 'solid-icons/si'
-import { JSXElement } from 'solid-js'
-import { BuildConfigMethod } from '../components/BuildConfigs'
-
-export const buildTypeStr: Record = {
- runtimeBuildpack: 'Runtime (Buildpack)',
- runtimeCmd: 'Runtime (command)',
- runtimeDockerfile: 'Runtime (Dockerfile)',
- staticBuildpack: 'Static (Buildpack)',
- staticCmd: 'Static (command)',
- staticDockerfile: 'Static (Dockerfile)',
-}
+import { colorVars } from '/@/theme'
export const buildStatusStr: Record = {
[BuildStatus.QUEUED]: 'Queued',
@@ -35,11 +26,18 @@ export enum ApplicationState {
Idle = 'Idle',
Deploying = 'Deploying',
Running = 'Running',
- Static = 'Static',
+ Serving = 'Serving',
Error = 'Error',
}
-const useDeployState = (app: Application): ApplicationState => {
+export const deploymentState = (app: Application): ApplicationState => {
+ if (!app.running) {
+ return ApplicationState.Idle
+ }
+ if (app.currentBuild === '') {
+ // First build may still be running
+ return ApplicationState.Idle
+ }
if (app.deployType === DeployType.RUNTIME) {
switch (app.container) {
case Application_ContainerState.MISSING:
@@ -54,7 +52,7 @@ const useDeployState = (app: Application): ApplicationState => {
return ApplicationState.Error
}
} else {
- return ApplicationState.Static
+ return ApplicationState.Serving
}
}
@@ -73,16 +71,20 @@ export const applicationState = (app: Application): ApplicationState => {
case BuildStatus.BUILDING:
return ApplicationState.Deploying
case BuildStatus.SUCCEEDED:
- return useDeployState(app)
+ return deploymentState(app)
case BuildStatus.FAILED:
return ApplicationState.Error
case BuildStatus.CANCELLED:
- return useDeployState(app)
+ return deploymentState(app)
case BuildStatus.SKIPPED:
- return useDeployState(app)
+ return deploymentState(app)
+ case undefined:
+ return ApplicationState.Error
}
}
+export type Provider = 'GitHub' | 'GitLab' | 'Gitea'
+
export const repositoryURLToProvider = (url: string): Provider => {
const normalizedURL = url.toLowerCase()
if (normalizedURL.includes('github')) return 'GitHub'
@@ -95,15 +97,15 @@ export const repositoryURLToProvider = (url: string): Provider => {
export const providerToIcon = (provider: Provider, size = 20): JSXElement => {
switch (provider) {
case 'GitHub':
- return
+ return
case 'GitLab':
return
case 'Gitea':
- return
+ return
}
}
-export const getWebsiteURL = (website: Website): string => {
+export const getWebsiteURL = (website: PlainMessage): string => {
const scheme = website.https ? 'https' : 'http'
return `${scheme}://${website.fqdn}${website.pathPrefix}`
}
diff --git a/dashboard/src/libs/branchesSuggestion.ts b/dashboard/src/libs/branchesSuggestion.ts
index 3104f69dc..2e4e32b9e 100644
--- a/dashboard/src/libs/branchesSuggestion.ts
+++ b/dashboard/src/libs/branchesSuggestion.ts
@@ -1,6 +1,6 @@
-import { client } from '/@/libs/api'
import Fuse from 'fuse.js'
import { createMemo, createResource } from 'solid-js'
+import { client } from '/@/libs/api'
export const useBranchesSuggestion = (repoID: () => string, current: () => string): (() => string[]) => {
const [refs] = createResource(
@@ -9,25 +9,24 @@ export const useBranchesSuggestion = (repoID: () => string, current: () => strin
)
const branches = createMemo(() => {
- if (!refs()) return
- const branches = refs()
- .refs.map((r) => r.refName)
- .filter((b) => !b.startsWith('refs/'))
- const normal = branches.filter((b) => !b.includes('/'))
- const long = branches.filter((b) => b.includes('/'))
- return [normal, long]
+ if (refs.state === 'ready') {
+ const branches = refs()
+ .refs.map((r) => r.refName)
+ .filter((b) => !b.startsWith('refs/'))
+ const normal = branches?.filter((b) => !b.includes('/'))
+ const long = branches?.filter((b) => b.includes('/'))
+ return [normal, long]
+ } else {
+ return [[], []]
+ }
})
const branchesFuse = createMemo(() => {
- if (!branches()) return
const [normal, long] = branches()
return [new Fuse(normal), new Fuse(long)]
})
return createMemo(() => {
const query = current()
-
- if (!branchesFuse()) return
-
if (!query) return branches()[0].concat(branches()[1])
const p0 = branchesFuse()[0]
@@ -39,3 +38,18 @@ export const useBranchesSuggestion = (repoID: () => string, current: () => strin
return p0.concat(p1)
})
}
+
+export const useBranches = (repoID: () => string): (() => string[]) => {
+ const [refs] = createResource(
+ () => repoID(),
+ (id) => client.getRepositoryRefs({ repositoryId: id }),
+ )
+
+ return createMemo(() => {
+ return (
+ refs()
+ ?.refs.map((r) => r.refName)
+ .filter((b) => !b.startsWith('refs/')) ?? []
+ )
+ })
+}
diff --git a/dashboard/src/libs/buffers.ts b/dashboard/src/libs/buffers.ts
index 4d263c882..f669823e2 100644
--- a/dashboard/src/libs/buffers.ts
+++ b/dashboard/src/libs/buffers.ts
@@ -11,7 +11,7 @@ const utf8Decoder = new TextDecoder('utf-8')
const ansiDecoder = new Convert()
const escapeHTML = (s: string): string =>
- s.replace(/[&'`"<>]/g, function (match) {
+ s.replace(/[&'`"<>]/g, (match) => {
return {
'&': '&',
"'": ''',
@@ -19,7 +19,7 @@ const escapeHTML = (s: string): string =>
'"': '"',
'<': '<',
'>': '>',
- }[match]
+ }[match] as string
})
export const toWithAnsi = (str: string): string => ansiDecoder.toHtml(escapeHTML(str))
diff --git a/dashboard/src/libs/casing.ts b/dashboard/src/libs/casing.ts
index bf8385494..7dad1bc70 100644
--- a/dashboard/src/libs/casing.ts
+++ b/dashboard/src/libs/casing.ts
@@ -1,2 +1,2 @@
export const titleCase = (s: string): string =>
- s.length === 0 ? s : s.at(0).toUpperCase() + s.substring(1).toLowerCase()
+ s.length === 0 ? s : s.at(0)?.toUpperCase() + s.substring(1).toLowerCase()
diff --git a/dashboard/src/libs/colorOverlay.ts b/dashboard/src/libs/colorOverlay.ts
new file mode 100644
index 000000000..28a47244c
--- /dev/null
+++ b/dashboard/src/libs/colorOverlay.ts
@@ -0,0 +1,2 @@
+export const colorOverlay = (baseColor: string, overlayColor: string) =>
+ `linear-gradient(0deg, ${overlayColor} 0%, ${overlayColor} 100%), ${baseColor}`
diff --git a/dashboard/src/libs/format.tsx b/dashboard/src/libs/format.tsx
index a23ba0080..d3e92bf1c 100644
--- a/dashboard/src/libs/format.tsx
+++ b/dashboard/src/libs/format.tsx
@@ -1,8 +1,4 @@
-import { createMemo } from 'solid-js'
-import { tippy as tippyDir } from 'solid-tippy'
-
-// https://github.com/solidjs/solid/discussions/845
-const tippy = tippyDir
+import { Timestamp } from '@bufbuild/protobuf'
export const shortSha = (sha1: string): string => sha1.substring(0, 7)
@@ -26,6 +22,19 @@ const minute = 60 * second
const hour = 60 * minute
const day = 24 * hour
+export const dateHuman = (timestamp: Timestamp): string => {
+ const date = new Date(Number(timestamp.seconds) * 1000)
+ // yyyy/MM/dd HH:mm
+ return date.toLocaleString('ja-JP', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
export const durationHuman = (millis: number): string => {
let remainMillis = millis
const days = Math.floor(remainMillis / day)
@@ -43,23 +52,13 @@ export const durationHuman = (millis: number): string => {
return `${remainMillis} ms`
}
-export interface DiffHumanProps {
- target: Date
-}
-
-export const DiffHuman = (props: DiffHumanProps) => {
- const diff = createMemo(() => new Date().getTime() - props.target.getTime())
- const suffix = () => (diff() > 0 ? 'ago' : 'from now')
- const human = () => durationHuman(Math.abs(diff()))
- const tooltip = () => props.target.toLocaleString()
- return (
-
- {human()} {suffix()}
-
- )
+export const diffHuman = (target: Date) => {
+ const diff = new Date().getTime() - target.getTime()
+ const suffix = diff > 0 ? 'ago' : 'from now'
+ const human = durationHuman(Math.abs(diff))
+ const localeString = target.toLocaleString()
+ return {
+ diff: `${human} ${suffix}`,
+ localeString,
+ }
}
diff --git a/dashboard/src/libs/layout.tsx b/dashboard/src/libs/layout.tsx
deleted file mode 100644
index a4879d4f9..000000000
--- a/dashboard/src/libs/layout.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-
-export const Container = styled('div', {
- base: {
- padding: '40px 72px',
- },
-})
-
-export const PageTitle = styled('div', {
- base: {
- marginTop: '48px',
- fontSize: '32px',
- fontWeight: 'bold',
- color: vars.text.black1,
- },
-})
-
-export const CenterInline = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- justifyContent: 'center',
- },
-})
diff --git a/dashboard/src/libs/useAllUsers.tsx b/dashboard/src/libs/useAllUsers.tsx
index ce20eb373..1246023c8 100644
--- a/dashboard/src/libs/useAllUsers.tsx
+++ b/dashboard/src/libs/useAllUsers.tsx
@@ -1,6 +1,6 @@
+import { createMemo, createResource, createRoot } from 'solid-js'
import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
import { client } from '/@/libs/api'
-import { createMemo, createResource, createRoot } from 'solid-js'
const [users, { mutate: mutateUsers, refetch: refetchUsers }] = createResource(async () => {
const getUsersRes = await client.getUsers({})
@@ -14,9 +14,13 @@ export { users, mutateUsers, refetchUsers }
// see: https://www.solidjs.com/docs/latest#createroot
const usersMap = createRoot(() =>
createMemo(() => {
- if (!users()) return new Map()
- return new Map(users().map((user) => [user.id, user]))
+ if (users.latest !== undefined) return new Map(users().map((user) => [user.id, user]))
+ return new Map()
}),
)
-export const userFromId = (id: string) => usersMap().get(id)
+export const userFromId = (id: string) => {
+ const user = usersMap().get(id)
+ if (user) return user
+ throw new Error(`userFromId: user not found: ${id}`)
+}
diff --git a/dashboard/src/libs/useClickInout.ts b/dashboard/src/libs/useClickInout.ts
deleted file mode 100644
index 4795bc4b9..000000000
--- a/dashboard/src/libs/useClickInout.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Accessor, onCleanup } from 'solid-js'
-
-declare module 'solid-js' {
- namespace JSX {
- interface Directives {
- clickInside: () => void
- clickOutside: () => void
- }
- }
-}
-
-export const clickInside = (el: HTMLDivElement, accessor: Accessor<() => void>) => {
- const onClick = (e) => el.contains(e.target) && accessor()?.()
- document.body.addEventListener('click', onClick)
- onCleanup(() => document.body.removeEventListener('click', onClick))
-}
-
-export const clickOutside = (el: HTMLDivElement, accessor: Accessor<() => void>) => {
- const onClick = (e) => !el.contains(e.target) && accessor()?.()
- document.body.addEventListener('click', onClick)
- onCleanup(() => document.body.removeEventListener('click', onClick))
-}
diff --git a/dashboard/src/libs/useModal.tsx b/dashboard/src/libs/useModal.tsx
index f1b1deda2..b0ba10335 100644
--- a/dashboard/src/libs/useModal.tsx
+++ b/dashboard/src/libs/useModal.tsx
@@ -1,66 +1,220 @@
-import { vars } from '/@/theme'
+import { Dialog } from '@kobalte/core'
+import { keyframes, style } from '@macaron-css/core'
import { styled } from '@macaron-css/solid'
-import { ParentComponent, Show, createSignal, onCleanup, onMount } from 'solid-js'
-import { Portal } from 'solid-js/web'
+import { ParentComponent, Show, createSignal, mergeProps } from 'solid-js'
+import { colorVars, textVars } from '/@/theme'
+import { MaterialSymbols } from '../components/UI/MaterialSymbols'
-const ModalWrapper = styled('div', {
- base: {
- position: 'relative',
- background: vars.bg.white1,
- borderRadius: '4px',
- padding: '24px',
+const overlayShow = keyframes({
+ from: {
+ opacity: 0,
+ },
+ to: {
+ opacity: 1,
+ },
+})
+const overlayHide = keyframes({
+ from: {
opacity: 1,
- minWidth: '400px',
+ },
+ to: {
+ opacity: 0,
},
})
-const ModalBackground = styled('div', {
+const overlayStyle = style({
+ position: 'fixed',
+ inset: 0,
+ background: colorVars.primitive.blackAlpha[600],
+ animation: `${overlayHide} 0.2s`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${overlayShow} 0.2s`,
+ },
+ },
+})
+const DialogPositioner = styled('div', {
base: {
position: 'fixed',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- background: 'rgba(0, 0, 0, 0.5)',
+ inset: 0,
+ padding: '32px',
display: 'grid',
placeItems: 'center',
},
})
+const contentShow = keyframes({
+ from: {
+ opacity: 0,
+ transform: 'scale(0.95)',
+ },
+ to: {
+ opacity: 1,
+ transform: 'scale(1)',
+ },
+})
+const contentHide = keyframes({
+ from: {
+ opacity: 1,
+ transform: 'scale(1)',
+ },
+ to: {
+ opacity: 0,
+ transform: 'scale(0.95)',
+ },
+})
+const contentStyle = style({
+ position: 'relative',
+ width: '100%',
+ maxWidth: '568px',
+ height: 'auto',
+ maxHeight: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '12px',
+ opacity: 1,
+ overflow: 'hidden',
+
+ animation: `${contentHide} 0.3s`,
+ selectors: {
+ '&[data-expanded]': {
+ animation: `${contentShow} 0.3s`,
+ },
+ },
+})
+const DialogHeader = styled('div', {
+ base: {
+ position: 'relative',
+ width: '100%',
+ height: '72px',
+ padding: '8px 32px',
+ flexShrink: 0,
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+
+ selectors: {
+ '&:not(:last-child)': {
+ borderBottom: `2px solid ${colorVars.semantic.ui.border}`,
+ },
+ },
+ },
+})
+const titleStyle = style({
+ color: colorVars.semantic.text.black,
+ ...textVars.h2.medium,
+})
+const descriptionStyle = style({
+ width: '100%',
+ height: 'auto',
+ maxHeight: '100%',
+ display: 'flex',
+ overflowY: 'hidden',
+ padding: '24px 32px',
+ selectors: {
+ '&:not(:last-child)': {
+ borderBottom: `2px solid ${colorVars.semantic.ui.border}`,
+ },
+ },
+})
+const ModalFooter = styled('div', {
+ base: {
+ width: '100%',
+ height: '72px',
+ padding: '8px 32px',
+ flexShrink: 0,
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ gap: '8px',
+ },
+})
+const closeButtonStyle = style({
+ position: 'absolute',
+ width: '24px',
+ height: '24px',
+ top: '24px',
+ right: '24px',
+ padding: '0',
+ background: 'none',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
-const useModal = (mount?: Node) => {
+ color: colorVars.semantic.text.black,
+ selectors: {
+ '&:hover': {
+ background: colorVars.semantic.transparent.primaryHover,
+ },
+ '&:active': {
+ color: colorVars.semantic.primary.main,
+ background: colorVars.semantic.transparent.primarySelected,
+ },
+ },
+})
+
+const useModal = (options?: {
+ showCloseButton?: boolean
+ closeOnClickOutside?: boolean
+}) => {
+ const defaultOptions = {
+ showCloseButton: false,
+ closeOnClickOutside: true,
+ }
+ const mergedProps = mergeProps(defaultOptions, options)
const [isOpen, setIsOpen] = createSignal(false)
// モーダルを開くときはopen()を呼ぶ
const open = () => setIsOpen(true)
// モーダルを閉じるときはclose()を呼ぶ
const close = () => setIsOpen(false)
- // ESCキーでモーダルを閉じる
- const closeOnEsc = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- close()
- }
+ const Container: ParentComponent = (props) => {
+ return (
+
+
+
+
+
+ {props.children}
+
+
+
+
+ )
}
- onMount(() => {
- document.addEventListener('keydown', closeOnEsc)
- })
- onCleanup(() => {
- document.removeEventListener('keydown', closeOnEsc)
- })
+ const Header: ParentComponent = (props) => {
+ return (
+
+ {props.children}
+
+
+ close
+
+
+
+ )
+ }
- const Modal: ParentComponent = (props) => {
+ const Body: ParentComponent = (props) => {
return (
-
-
-
- e.stopPropagation()}>{props.children}
-
-
-
+
+ {props.children}
+
)
}
return {
- Modal,
+ Modal: {
+ Container,
+ Header,
+ Body,
+ Footer: ModalFooter,
+ },
open,
close,
isOpen,
diff --git a/dashboard/src/pages/apps.tsx b/dashboard/src/pages/apps.tsx
index 98e032cd1..2bcab8e87 100644
--- a/dashboard/src/pages/apps.tsx
+++ b/dashboard/src/pages/apps.tsx
@@ -1,264 +1,323 @@
+import { styled } from '@macaron-css/solid'
+import { Title } from '@solidjs/meta'
+import { A } from '@solidjs/router'
+import { createVirtualizer } from '@tanstack/solid-virtual'
+import Fuse from 'fuse.js'
+import { Component, For, Suspense, createMemo, createResource, createSignal, useTransition } from 'solid-js'
import {
Application,
GetApplicationsRequest_Scope,
GetRepositoriesRequest_Scope,
Repository,
} from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { AppStatus } from '/@/components/AppStatus'
-import { Button } from '/@/components/Button'
-import { Checkbox } from '/@/components/Checkbox'
-import { Header } from '/@/components/Header'
-import { Radio, RadioItem } from '/@/components/Radio'
-import { RepositoryRow } from '/@/components/RepositoryRow'
+import { SelectOption } from '/@/components/templates/Select'
import { client, user } from '/@/libs/api'
-import { ApplicationState, applicationState } from '/@/libs/application'
-import { Container, PageTitle } from '/@/libs/layout'
+import { ApplicationState, Provider, applicationState, repositoryURLToProvider } from '/@/libs/application'
import { createLocalSignal } from '/@/libs/localStore'
-import { unique } from '/@/libs/unique'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { useNavigate } from '@solidjs/router'
-import Fuse from 'fuse.js'
-import { For, Show, createMemo, createResource } from 'solid-js'
-
-const sortItems: RadioItem<'asc' | 'desc'>[] = [
- { value: 'desc', title: '最新順' },
- { value: 'asc', title: '古い順' },
-]
-
-const scopeItems = (admin: boolean) => {
- const items: RadioItem[] = [
- { value: GetRepositoriesRequest_Scope.MINE, title: '自分のアプリ' },
- { value: GetRepositoriesRequest_Scope.PUBLIC, title: 'すべてのアプリ' },
- ]
- if (admin) {
- items.push({ value: GetRepositoriesRequest_Scope.ALL, title: 'すべてのアプリ (admin)' })
- }
- return items
-}
+import { Button } from '../components/UI/Button'
+import { MaterialSymbols } from '../components/UI/MaterialSymbols'
+import { TabRound } from '../components/UI/TabRound'
+import { TextField } from '../components/UI/TextField'
+import SuspenseContainer from '../components/layouts/SuspenseContainer'
+import { WithNav } from '../components/layouts/WithNav'
+import { AppsNav } from '../components/templates/AppsNav'
+import { List, RepositoryList } from '../components/templates/List'
+import AppsFilter from '../components/templates/app/AppsFilter'
+import { colorVars, media } from '../theme'
-const ContentContainer = styled('div', {
+const MainView = styled('div', {
base: {
- marginTop: '24px',
- display: 'grid',
- gridTemplateColumns: '380px 1fr',
- gap: '40px',
- },
-})
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ overflowY: 'auto',
+ padding: '0 max(calc(50% - 500px), 32px)',
+ background: colorVars.semantic.ui.background,
-const SidebarContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '22px',
-
- padding: '24px 40px',
- backgroundColor: vars.bg.white1,
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- },
-})
-
-const SidebarSection = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '16px',
- },
-})
-
-const SidebarTitle = styled('div', {
- base: {
- fontSize: '24px',
- fontWeight: 500,
- color: vars.text.black1,
+ '@media': {
+ [media.mobile]: {
+ padding: '0 16px',
+ },
+ },
},
})
-
-const SidebarOptions = styled('div', {
+const FilterContainer = styled('div', {
base: {
+ position: 'sticky',
+ width: '100%',
+ top: '0',
+ left: '0',
+ padding: '40px 0 32px',
+ zIndex: 1,
display: 'flex',
- flexDirection: 'column',
- gap: '12px',
-
- fontSize: '20px',
- color: vars.text.black1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: `linear-gradient(0deg, rgba(255,255,255,0), ${colorVars.semantic.ui.background} 20px)`,
},
})
-
-const MainContainer = styled('div', {
+const Repositories = styled('div', {
base: {
+ width: '100%',
display: 'flex',
flexDirection: 'column',
- gap: '20px',
- },
-})
-
-const SearchBarContainer = styled('div', {
- base: {
- display: 'grid',
- gridTemplateColumns: '1fr 180px',
- gap: '20px',
- height: '44px',
- },
-})
-
-const SearchBar = styled('input', {
- base: {
- padding: '12px 20px',
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- fontSize: '14px',
-
- '::placeholder': {
- color: vars.text.black3,
- },
+ gap: '16px',
},
})
-const RepositoriesContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '20px',
- },
-})
+export const sortItems: { [k in 'desc' | 'asc']: SelectOption } = {
+ desc: { value: 'desc', label: 'Newest' },
+ asc: { value: 'asc', label: 'Oldest' },
+}
+const scopeItems = (admin: boolean | undefined) => {
+ const items: SelectOption[] = [
+ { value: GetRepositoriesRequest_Scope.MINE, label: 'My Apps' },
+ { value: GetRepositoriesRequest_Scope.PUBLIC, label: 'All Apps' },
+ ]
+ if (admin) {
+ items.push({
+ value: GetRepositoriesRequest_Scope.ALL,
+ label: 'All Apps (admin)',
+ })
+ }
+ return items
+}
interface RepoWithApp {
repo: Repository
apps: Application[]
}
-const newestAppDate = (apps: Application[]): number => Math.max(0, ...apps.map((a) => a.updatedAt.toDate().getTime()))
-const compareRepoWithApp = (sort: 'asc' | 'desc') => (a: RepoWithApp, b: RepoWithApp): number => {
- // Sort by apps updated at
- if (a.apps.length > 0 && b.apps.length > 0) {
- if (sort === 'asc') {
- return newestAppDate(a.apps) - newestAppDate(b.apps)
- } else {
- return newestAppDate(b.apps) - newestAppDate(a.apps)
+const newestAppDate = (apps: Application[]): number =>
+ Math.max(0, ...apps.map((a) => a.updatedAt?.toDate().getTime() ?? 0))
+const compareRepoWithApp =
+ (sort: 'asc' | 'desc') =>
+ (a: RepoWithApp, b: RepoWithApp): number => {
+ // Sort by apps updated at
+ if (a.apps.length > 0 && b.apps.length > 0) {
+ if (sort === 'asc') {
+ return newestAppDate(a.apps) - newestAppDate(b.apps)
+ } else {
+ return newestAppDate(b.apps) - newestAppDate(a.apps)
+ }
}
+ // Bring up repositories with 1 or more apps at top
+ if ((a.apps.length > 0 && b.apps.length === 0) || (a.apps.length === 0 && b.apps.length > 0)) {
+ return b.apps.length - a.apps.length
+ }
+ // Fallback to sort by repository id
+ return a.repo.id.localeCompare(b.repo.id)
}
- // Bring up repositories with 1 or more apps at top
- if ((a.apps.length > 0 && b.apps.length === 0) || (a.apps.length === 0 && b.apps.length > 0)) {
- return b.apps.length - a.apps.length
- }
- // Fallback to sort by repository id
- return a.repo.id.localeCompare(b.repo.id)
-}
-const allStatuses = [
- ApplicationState.Idle,
- ApplicationState.Deploying,
- ApplicationState.Running,
- ApplicationState.Static,
- ApplicationState.Error,
+export const allStatuses: SelectOption[] = [
+ { label: 'Idle', value: ApplicationState.Idle },
+ { label: 'Deploying', value: ApplicationState.Deploying },
+ { label: 'Running', value: ApplicationState.Running },
+ { label: 'Serving', value: ApplicationState.Serving },
+ { label: 'Error', value: ApplicationState.Error },
+]
+export const allProviders: SelectOption[] = [
+ { label: 'GitHub', value: 'GitHub' },
+ { label: 'GitLab', value: 'GitLab' },
+ { label: 'Gitea', value: 'Gitea' },
]
-export default () => {
- const navigate = useNavigate()
-
- const [statuses, setStatuses] = createLocalSignal('apps-statuses', [...allStatuses])
- const checkStatus = (status: ApplicationState, checked: boolean) => {
- if (checked) {
- setStatuses((statuses) => unique([status, ...statuses]))
- } else {
- setStatuses((statuses) => statuses.filter((s) => s !== status))
- }
- }
-
- const [scope, setScope] = createLocalSignal('apps-scope', GetRepositoriesRequest_Scope.MINE)
+const AppsList: Component<{
+ scope: GetRepositoriesRequest_Scope
+ statuses: ApplicationState[]
+ provider: Provider[]
+ query: string
+ sort: keyof typeof sortItems
+ includeNoApp: boolean
+ parentRef: HTMLDivElement
+}> = (props) => {
const appScope = () => {
- const mine = scope() === GetRepositoriesRequest_Scope.MINE
+ const mine = props.scope === GetRepositoriesRequest_Scope.MINE
return mine ? GetApplicationsRequest_Scope.MINE : GetApplicationsRequest_Scope.ALL
}
- const [query, setQuery] = createLocalSignal('apps-query', '')
- const [sort, setSort] = createLocalSignal('apps-sort', sortItems[0].value)
-
const [repos] = createResource(
- () => scope(),
+ () => props.scope,
(scope) => client.getRepositories({ scope }),
)
const [apps] = createResource(
() => appScope(),
(scope) => client.getApplications({ scope }),
)
- const loaded = () => !!(user() && repos() && apps())
+ const filteredReposByProvider = createMemo(() => {
+ const p = props.provider
+ return repos()?.repositories.filter((r) => p.includes(repositoryURLToProvider(r.url))) ?? []
+ })
const filteredApps = createMemo(() => {
- if (!apps()) return
- const s = statuses()
- return apps().applications.filter((a) => s.includes(applicationState(a)))
+ const s = props.statuses
+ return apps()?.applications.filter((a) => s.includes(applicationState(a))) ?? []
})
const repoWithApps = createMemo(() => {
- if (!repos() || !filteredApps()) return
const appsMap = {} as Record
for (const app of filteredApps()) {
if (!appsMap[app.repositoryId]) appsMap[app.repositoryId] = []
appsMap[app.repositoryId].push(app)
}
- const res = repos().repositories.map((repo): RepoWithApp => ({ repo, apps: appsMap[repo.id] || [] }))
- res.sort(compareRepoWithApp(sort()))
+ const res = filteredReposByProvider().reduce((acc, repo) => {
+ if (!props.includeNoApp && !appsMap[repo.id]) return acc
+ acc.push({ repo, apps: appsMap[repo.id] || [] })
+ return acc
+ }, [])
+ res.sort(compareRepoWithApp(props.sort))
return res
})
const fuse = createMemo(() => {
- if (!repoWithApps()) return
return new Fuse(repoWithApps(), {
keys: ['repo.name', 'apps.name'],
})
})
const filteredRepos = createMemo(() => {
- if (!repoWithApps()) return
- if (query() === '') return repoWithApps()
+ if (props.query === '') return repoWithApps()
return fuse()
- .search(query())
+ .search(props.query)
.map((r) => r.item)
})
+ const virtualizer = createMemo(() =>
+ createVirtualizer({
+ count: filteredRepos().length,
+ getScrollElement: () => props.parentRef,
+ estimateSize: (i) => 76 + 16 + filteredRepos()[i].apps.length * 80,
+ paddingEnd: 72,
+ }),
+ )
+
+ const items = () => virtualizer().getVirtualItems()
+
+ return (
+
+
+
+
+ search
+ No Apps Found
+
+
+ }
+ >
+ {(vRow) => (
+
+ )}
+
+
+
+ )
+}
+
+export default () => {
+ const [scope, _setScope] = createLocalSignal('apps-scope', GetRepositoriesRequest_Scope.MINE)
+ const [isPending, start] = useTransition()
+
+ const setScope = (scope: GetRepositoriesRequest_Scope) => {
+ start(() => {
+ _setScope(scope)
+ })
+ }
+
+ const [statuses, setStatuses] = createLocalSignal(
+ 'apps-statuses',
+ allStatuses.map((s) => s.value),
+ )
+ const [provider, setProvider] = createLocalSignal('apps-provider', ['GitHub', 'GitLab', 'Gitea'])
+ const [query, setQuery] = createLocalSignal('apps-query', '')
+ const [sort, setSort] = createLocalSignal('apps-sort', sortItems.desc.value)
+ const [includeNoApp, setIncludeNoApp] = createLocalSignal('apps-include-no-app', false)
+
+ const [scrollParentRef, setScrollParentRef] = createSignal()
+
return (
-
-
- Apps
-
-
-
-
- Status
-
-
- {(status) => (
- checkStatus(status, s)}>
-
-
- )}
-
-
-
-
- Scope
-
-
-
-
-
- Sort
-
-
-
-
-
- setQuery(e.target.value)} placeholder="Search..." />
-
-
-
- {(r) => }
-
-
-
-
-
+
+ Apps - NeoShowcase
+
+
+
+
+ {(s) => (
+ setScope(s.value)}>
+ deployed_code
+ {s.label}
+
+ )}
+
+
+
+
+
+
+
+
+
+ setQuery(e.currentTarget.value)}
+ leftIcon={search}
+ rightIcon={
+
+ }
+ />
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
)
}
diff --git a/dashboard/src/pages/apps/[id].tsx b/dashboard/src/pages/apps/[id].tsx
index 95fceedea..3694cf861 100644
--- a/dashboard/src/pages/apps/[id].tsx
+++ b/dashboard/src/pages/apps/[id].tsx
@@ -1,467 +1,57 @@
-import {
- ApplicationConfig,
- Application_ContainerState,
- DeployType,
- RuntimeConfig,
- StaticConfig,
-} from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { AppMetrics } from '/@/components/AppMetrics'
-import { AppNav } from '/@/components/AppNav'
-import { Button } from '/@/components/Button'
-import {
- Card,
- CardItem,
- CardItemContent,
- CardItemTitle,
- CardItems,
- CardRowsContainer,
- CardTitle,
- CardsRow,
-} from '/@/components/Card'
-import { Checkbox } from '/@/components/Checkbox'
-import { ContainerLog } from '/@/components/ContainerLog'
-import { Header } from '/@/components/Header'
-import { ModalButtonsContainer, ModalContainer, ModalText } from '/@/components/Modal'
-import { StatusIcon } from '/@/components/StatusIcon'
-import { URLText } from '/@/components/URLText'
-import { availableMetrics, client, handleAPIError, systemInfo } from '/@/libs/api'
-import {
- applicationState,
- buildTypeStr,
- getWebsiteURL,
- providerToIcon,
- repositoryURLToProvider,
-} from '/@/libs/application'
-import { titleCase } from '/@/libs/casing'
-import { DiffHuman, shortSha } from '/@/libs/format'
-import { CenterInline, Container } from '/@/libs/layout'
-import { unreachable } from '/@/libs/unreachable'
-import useModal from '/@/libs/useModal'
-import { vars } from '/@/theme'
-import { styled } from '@macaron-css/solid'
-import { A, useNavigate, useParams } from '@solidjs/router'
-import { Component, For, Show, createResource, createSignal, onCleanup } from 'solid-js'
-import toast from 'solid-toast'
-
-const CodeAlignRight = styled('pre', {
- base: {
- display: 'block',
- width: '100%',
- textAlign: 'right',
- },
-})
-
-const RuntimeConfigInfo: Component<{ config: RuntimeConfig }> = (props) => {
- return (
- <>
-
- Use MariaDB
- {`${props.config.useMariadb}`}
-
-
- Use MongoDB
- {`${props.config.useMongodb}`}
-
-
-
- Entrypoint
- {props.config.entrypoint}
-
-
-
-
- Command
- {props.config.command}
-
-
- >
- )
-}
-
-const StaticConfigInfo: Component<{ config: StaticConfig }> = (props) => {
- return (
- <>
-
- Artifact Path
- {props.config.artifactPath}
-
-
- Single Page Application
- {props.config.spa}
-
- >
- )
-}
-
-const ApplicationConfigInfo: Component<{ config: ApplicationConfig }> = (props) => {
- const c = props.config.buildConfig
- switch (c.case) {
- case 'runtimeBuildpack':
- return (
- <>
-
- Context
- {c.value.context}
-
-
- >
- )
- case 'runtimeCmd':
- return (
- <>
-
- Base Image
- {c.value.baseImage || 'Scratch'}
-
- {c.value.buildCmd && (
- <>
-
- Build Command
-
-
- {c.value.buildCmd}
-
- >
- )}
-
- >
- )
- case 'runtimeDockerfile':
- return (
- <>
-
- Dockerfile
- {c.value.dockerfileName}
-
-
- Context
- {c.value.context}
-
-
- >
- )
- case 'staticBuildpack':
- return (
- <>
-
-
- Context
- {c.value.context}
-
- >
- )
- case 'staticCmd':
- return (
- <>
-
-
- Base Image
- {c.value.baseImage || 'Scratch'}
-
- {c.value.buildCmd && (
-
- Build Command
- {c.value.buildCmd}
-
- )}
- >
- )
- case 'staticDockerfile':
- return (
- <>
-
-
- Dockerfile
- {c.value.dockerfileName}
-
-
- Context
- {c.value.context}
-
- >
- )
- }
- return unreachable(c)
-}
-
-const URLsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '8px',
- },
-})
-
-const SSHCode = styled('code', {
- base: {
- display: 'block',
- padding: '8px 12px',
- fontFamily: 'monospace',
- fontSize: '14px',
- background: vars.bg.white2,
- color: vars.text.black1,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- },
-})
+import { Title } from '@solidjs/meta'
+import { Outlet, useMatch, useNavigate } from '@solidjs/router'
+import { ErrorBoundary, Show, startTransition } from 'solid-js'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { TabRound } from '/@/components/UI/TabRound'
+import ErrorView from '/@/components/layouts/ErrorView'
+import { WithNav } from '/@/components/layouts/WithNav'
+import { AppNav } from '/@/components/templates/app/AppNav'
+import { useApplicationData } from '/@/routes'
export default () => {
- const navigate = useNavigate()
- const params = useParams()
- const id = params.id
- const [app, { refetch: refetchApp }] = createResource(
- () => id,
- (id) => client.getApplication({ id }),
- )
- const [repo] = createResource(
- () => app()?.repositoryId,
- (id) => client.getRepository({ repositoryId: id }),
- )
- const loaded = () => !!(systemInfo() && app() && repo())
-
- const refetchTimer = setInterval(refetchApp, 10000)
- onCleanup(() => clearInterval(refetchTimer))
+ const { app, repo } = useApplicationData()
+ const loaded = () => !!(app() && repo())
- const [disableRefresh, setDisableRefresh] = createSignal(false)
- const refreshRepo = async () => {
- setDisableRefresh(true)
- setTimeout(() => setDisableRefresh(false), 3000)
- await client.refreshRepository({ repositoryId: repo().id })
- await refetchApp()
- }
- const startApp = async () => {
- await client.startApplication({ id: app().id })
- await refetchApp()
- }
- const stopApp = async () => {
- await client.stopApplication({ id: app().id })
- await refetchApp()
- }
- const deleteApp = async () => {
- try {
- await client.deleteApplication({ id: app().id })
- } catch (e) {
- handleAPIError(e, 'アプリケーションの削除に失敗しました')
- return
- }
- toast.success('アプリケーションを削除しました')
- navigate('/apps')
- }
- const { Modal: DeleteAppModal, open: openDeleteAppModal, close: closeDeleteAppModal } = useModal()
+ const matchIndexPage = useMatch(() => `/apps/${app()?.id}/`)
+ const matchBuildsPage = useMatch(() => `/apps/${app()?.id}/builds/*`)
+ const matchSettingsPage = useMatch(() => `/apps/${app()?.id}/settings/*`)
- const [showLogTimestamp, setShowLogTimestamp] = createSignal(true)
+ const navigator = useNavigate()
+ const navigate = (path: string) => startTransition(() => navigator(path))
return (
-
-
+
-
-
-
-
- Actions
-
-
-
-
-
- 本当に削除しますか?
- データベースを利用している場合、中身のデータも削除されます
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Overall
-
-
- 状態
-
-
- {applicationState(app())}
-
-
-
-
- コンテナの状態
- {app() && titleCase(Application_ContainerState[app().container])}
-
-
-
- {app().containerMessage}
-
-
-
-
- 起動時刻
-
-
-
-
-
-
-
- 作成日
-
-
-
-
- 0}>
-
- URLs
-
-
-
-
-
- {(website) => }
-
-
-
-
-
-
-
-
- Info
-
-
- ID
- {app().id}
-
-
- Name
- {app().name}
-
-
-
- Repository
-
- {providerToIcon(repositoryURLToProvider(repo().url), 20)}
- {repo().name}
-
-
-
-
- Git ref (short)
- {app().refName}
-
-
- Deploy type
- {titleCase(DeployType[app().deployType])}
-
-
- Commit
-
- {shortSha(app().commit)}
-
-
-
- Deployed build
-
- {shortSha(app().currentBuild)}
-
-
-
-
-
- Config
-
-
- Build Type
- {buildTypeStr[app().config.buildConfig.case]}
-
-
-
-
-
-
-
-
- {(name) => (
-
- {name}
-
-
-
-
- )}
-
-
-
-
-
-
- SSH Access
-
- アプリケーションが起動している間のみSSHでアクセス可能です}
- >
-
- {`ssh -p ${systemInfo().ssh.port} ${app().id}@${systemInfo().ssh.host}`}
-
-
-
-
-
-
-
-
-
- Container Log
-
- Show Timestamps
-
-
-
-
-
-
+ {`${app()?.name} - Application - NeoShowcase`}
+
+
+
+ navigate(`/apps/${app()?.id}`)} state={matchIndexPage() ? 'active' : 'default'}>
+ insert_chart
+ Info
+
+ navigate(`/apps/${app()?.id}/builds`)}
+ state={matchBuildsPage() ? 'active' : 'default'}
+ >
+ history
+ Build History
+
+ navigate(`/apps/${app()?.id}/settings`)}
+ state={matchSettingsPage() ? 'active' : 'default'}
+ >
+ settings
+ Settings
+
+
+
-
+
+ }>
+
+
+
+
)
}
diff --git a/dashboard/src/pages/apps/[id]/builds.tsx b/dashboard/src/pages/apps/[id]/builds.tsx
index d389255ed..5eb085f8a 100644
--- a/dashboard/src/pages/apps/[id]/builds.tsx
+++ b/dashboard/src/pages/apps/[id]/builds.tsx
@@ -1,43 +1,54 @@
-import { AppNav } from '/@/components/AppNav'
-import { BuildList } from '/@/components/BuildList'
-import { Header } from '/@/components/Header'
-import { client } from '/@/libs/api'
-import { Container } from '/@/libs/layout'
-import { useParams } from '@solidjs/router'
-import { createMemo, createResource } from 'solid-js'
+import { createMemo, createResource, useTransition } from 'solid-js'
import { Show } from 'solid-js'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { DataTable } from '/@/components/layouts/DataTable'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import SuspenseContainer from '/@/components/layouts/SuspenseContainer'
+import { BuildList, List } from '/@/components/templates/List'
+import { client } from '/@/libs/api'
+import { useApplicationData } from '/@/routes'
export default () => {
- const params = useParams()
- const [app] = createResource(
- () => params.id,
- (id) => client.getApplication({ id }),
- )
- const [repo] = createResource(
- () => app()?.repositoryId,
- (id) => client.getRepository({ repositoryId: id }),
- )
+ const { app } = useApplicationData()
const [builds] = createResource(
- () => params.id,
+ () => app()?.id,
(id) => client.getBuilds({ id }),
)
- const loaded = () => !!(app() && repo() && builds())
+ const loaded = () => !!(app() && builds())
- const sortedBuilds = createMemo(
- () =>
- builds() &&
- [...builds().builds].sort((b1, b2) => {
- return b2.queuedAt.toDate().getTime() - b1.queuedAt.toDate().getTime()
- }),
+ const sortedBuilds = createMemo(() =>
+ builds.latest !== undefined
+ ? [...builds().builds]
+ .sort((b1, b2) => {
+ return (b2.queuedAt?.toDate().getTime() ?? 0) - (b1.queuedAt?.toDate().getTime() ?? 0)
+ })
+ .map((b) => ({ build: b }))
+ : [],
)
+ const showPlaceHolder = () => builds()?.builds.length === 0
+
+ const [isPending] = useTransition()
return (
-
-
-
-
-
-
-
+
+
+
+
+ Builds
+ }
+ >
+
+
+ deployed_code
+ No Builds
+
+
+
+
+
+
+
)
}
diff --git a/dashboard/src/pages/apps/[id]/builds/[id].tsx b/dashboard/src/pages/apps/[id]/builds/[id].tsx
index a655565b5..3cb74fa5c 100644
--- a/dashboard/src/pages/apps/[id]/builds/[id].tsx
+++ b/dashboard/src/pages/apps/[id]/builds/[id].tsx
@@ -1,166 +1,77 @@
-import { BuildStatus } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { AppNav } from '/@/components/AppNav'
-import { ArtifactRow } from '/@/components/ArtifactRow'
-import { BuildLog } from '/@/components/BuildLog'
-import { BuildStatusIcon } from '/@/components/BuildStatusIcon'
-import { Button } from '/@/components/Button'
-import {
- Card,
- CardItem,
- CardItemContent,
- CardItemTitle,
- CardItems,
- CardRowsContainer,
- CardTitle,
- CardsRow,
-} from '/@/components/Card'
-import { Header } from '/@/components/Header'
-import { client } from '/@/libs/api'
-import { buildStatusStr } from '/@/libs/application'
-import { DiffHuman, durationHuman, shortSha } from '/@/libs/format'
-import { Container } from '/@/libs/layout'
import { styled } from '@macaron-css/solid'
-import { useNavigate, useParams } from '@solidjs/router'
-import { For, Ref, createEffect, createResource, createSignal, onCleanup } from 'solid-js'
-import { Show } from 'solid-js'
+import { Title } from '@solidjs/meta'
+import { For, Show, createResource } from 'solid-js'
+import { DataTable } from '/@/components/layouts/DataTable'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import { List } from '/@/components/templates/List'
+import { ArtifactRow } from '/@/components/templates/build/ArtifactRow'
+import { BuildLog } from '/@/components/templates/build/BuildLog'
+import BuildStatusTable from '/@/components/templates/build/BuildStatusTable'
+import { client } from '/@/libs/api'
+import { useBuildData } from '/@/routes'
+import { colorVars } from '/@/theme'
-const ArtifactsContainer = styled('div', {
+const MainView = styled('div', {
base: {
+ width: '100%',
display: 'flex',
flexDirection: 'column',
+ gap: '32px',
+ },
+})
+const LogContainer = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
},
})
export default () => {
- const navigate = useNavigate()
- const params = useParams()
- const [app] = createResource(
- () => params.id,
- (id) => client.getApplication({ id }),
- )
+ const { app, build, refetchBuild, hasPermission } = useBuildData()
const [repo] = createResource(
() => app()?.repositoryId,
(id) => client.getRepository({ repositoryId: id }),
)
- const [build, { refetch: refetchBuild }] = createResource(
- () => params.buildID,
- (id) => client.getBuild({ buildId: id }),
- )
const loaded = () => !!(app() && repo() && build())
- const buildFinished = () => build()?.finishedAt.valid
-
- const retryBuild = async () => {
- await client.retryCommitBuild({ applicationId: params.id, commit: build().commit })
- navigate(`/apps/${app().id}/builds`)
- }
-
- const cancelBuild = async () => {
- await client.cancelBuild({ buildId: build().id })
- await refetchBuild()
- }
+ const buildFinished = () => build()?.finishedAt?.valid ?? false
return (
-
-
+
-
-
-
-
- Actions
-
-
-
-
-
-
- Info
-
-
- ID
- {build().id}
-
-
- Commit
- {shortSha(build().commit)}
-
-
- Status
-
-
- {buildStatusStr[build().status]}
-
-
-
- Queued at
-
-
-
-
-
- Started at
-
-
-
-
-
-
-
- Finished at
-
-
-
-
-
-
-
- Duration
-
-
- {durationHuman(
- build().finishedAt.timestamp.toDate().getTime() -
- build().startedAt.timestamp.toDate().getTime(),
- )}
-
-
-
-
- Retried
- {build().retriable ? 'Yes' : 'No'}
-
-
-
-
- 0}>
-
-
- Artifacts
-
- {(artifact) => }
-
-
-
+ {`${app()!.name} - Build - NeoShowcase`}
+
+
+ Build Status
+
+
+ 0}>
+
+ Artifacts
+
+ {(artifact) => }
+
+
+
+
+
+ Build Log
+
+
+
+
-
-
- Build Log
-
-
-
-
+
-
+
)
}
diff --git a/dashboard/src/pages/apps/[id]/index.tsx b/dashboard/src/pages/apps/[id]/index.tsx
new file mode 100644
index 000000000..d79108b93
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/index.tsx
@@ -0,0 +1,250 @@
+import { styled } from '@macaron-css/solid'
+import { Component, For, Show, createResource, createSignal, onCleanup, useTransition } from 'solid-js'
+
+import { Application, DeployType } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { DataTable } from '/@/components/layouts/DataTable'
+import SuspenseContainer from '/@/components/layouts/SuspenseContainer'
+import AppDeployInfo from '/@/components/templates/app/AppDeployInfo'
+import AppInfoLists from '/@/components/templates/app/AppInfoLists'
+import AppLatestBuilds from '/@/components/templates/app/AppLatestBuilds'
+import { AppMetrics } from '/@/components/templates/app/AppMetrics'
+import { ContainerLog } from '/@/components/templates/app/ContainerLog'
+import { availableMetrics, client, handleAPIError } from '/@/libs/api'
+import { useApplicationData } from '/@/routes'
+import { colorVars, media } from '/@/theme'
+
+import toast from 'solid-toast'
+
+const Container = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ overflowY: 'auto',
+ scrollbarGutter: 'stable',
+ },
+})
+const MainViewContainer = styled('div', {
+ base: {
+ width: '100%',
+ padding: '40px 32px 72px',
+
+ '@media': {
+ [media.mobile]: {
+ padding: '40px 16px 72px',
+ },
+ },
+ },
+ variants: {
+ gray: {
+ true: {
+ background: colorVars.semantic.ui.background,
+ },
+ false: {
+ background: colorVars.semantic.ui.primary,
+ },
+ },
+ },
+})
+const MainView = styled('div', {
+ base: {
+ width: '100%',
+ maxWidth: '1000px',
+ margin: '0 auto',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '32px',
+ },
+})
+
+const MetricsContainer = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '16px',
+
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
+ },
+})
+const MetricsTypeButtons = styled('div', {
+ base: {
+ display: 'flex',
+ flexDirection: 'row',
+ },
+})
+const ChartContainer = styled('div', {
+ base: {
+ width: '100%',
+ borderRadius: '8px',
+ height: 'auto',
+ aspectRatio: '960 / 464',
+
+ background: colorVars.semantic.ui.secondary,
+ },
+})
+
+const Metrics: Component<{ app: Application }> = (props) => {
+ const metricsNames = () => availableMetrics()?.metricsNames ?? []
+ const [currentView, setCurrentView] = createSignal(metricsNames()[0])
+
+ return (
+
+
+
+ {(metrics) => (
+
+ )}
+
+
+
+
+ {(metrics) => (
+
+
+
+ )}
+
+
+
+ )
+}
+
+const LogContainer = styled('div', {
+ base: {
+ width: '100%',
+ padding: '16px 20px',
+
+ border: `1px solid ${colorVars.semantic.ui.border}`,
+ borderRadius: '8px',
+ },
+})
+
+const Logs: Component<{ app: Application }> = (props) => {
+ return (
+
+
+
+ )
+}
+
+export default () => {
+ const { app, refetchApp, repo, hasPermission } = useApplicationData()
+
+ const [builds, { refetch: refetchBuilds }] = createResource(
+ () => app()?.id,
+ (id) => client.getBuilds({ id }),
+ )
+ const sortedBuilds = () =>
+ builds()?.builds.sort((b1, b2) => {
+ return (b2.queuedAt?.toDate().getTime() ?? 0) - (b1.queuedAt?.toDate().getTime() ?? 0)
+ })
+ const deployedBuild = () => sortedBuilds()?.find((b) => b.id === app()?.currentBuild)
+ const latestBuild = () => sortedBuilds()?.[0]
+
+ const loaded = () => !!(app() && repo())
+ const refetch = async () => {
+ await Promise.all([refetchApp(), refetchBuilds()])
+ }
+
+ const startApp = async () => {
+ const wasRunning = app()?.running
+ try {
+ await client.startApplication({ id: app()?.id })
+ await refetch()
+ // 非同期でビルドが開始されるので1秒程度待ってから再度リロード
+ setTimeout(refetch, 1000)
+ toast.success(`アプリケーションを${wasRunning ? '再' : ''}起動しました`)
+ } catch (e) {
+ handleAPIError(e, `アプリケーションの${wasRunning ? '再' : ''}起動に失敗しました`)
+ }
+ }
+
+ const [disableRefreshCommit, setDisableRefreshCommit] = createSignal(false)
+ const refreshCommit = async () => {
+ setDisableRefreshCommit(true)
+ await client.refreshRepository({ repositoryId: repo()?.id })
+ setTimeout(() => {
+ // バックエンド側で非同期で取得されるので1秒程度待ってからリロード
+ setDisableRefreshCommit(false)
+ void refetch()
+ }, 1000)
+ }
+
+ const refetchTimer = setInterval(refetch, 10000)
+ onCleanup(() => clearInterval(refetchTimer))
+
+ const [isPending] = useTransition()
+
+ return (
+
+
+
+
+
+
+ Deployment
+
+
+
+
+
+
+
+
+ Latest Builds
+
+
+
+
+ Information
+
+
+
+
+ Usage
+
+
+
+
+
+ Container Log
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings.tsx b/dashboard/src/pages/apps/[id]/settings.tsx
index 171261a09..e285d77c6 100644
--- a/dashboard/src/pages/apps/[id]/settings.tsx
+++ b/dashboard/src/pages/apps/[id]/settings.tsx
@@ -1,591 +1,132 @@
-import { ApplicationEnvVar, DeployType, UpdateApplicationRequest, User } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { AppNav } from '/@/components/AppNav'
-import { FormTextBig } from '/@/components/AppsNew'
-import { BuildConfigs } from '/@/components/BuildConfigs'
-import { Button } from '/@/components/Button'
-import { Header } from '/@/components/Header'
-import { IconButton } from '/@/components/IconButton'
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { InputBar, InputLabel } from '/@/components/Input'
-import { InputSuggestion } from '/@/components/InputSuggestion'
-import { ModalButtonsContainer, ModalContainer, ModalText } from '/@/components/Modal'
-import { PortPublicationSettings } from '/@/components/PortPublications'
-import { UserSearch } from '/@/components/UserSearch'
-import { WebsiteSettings } from '/@/components/WebsiteSettings'
-import { client, handleAPIError } from '/@/libs/api'
-import { useBranchesSuggestion } from '/@/libs/branchesSuggestion'
-import { writeToClipboard } from '/@/libs/clipboard'
-import { Container } from '/@/libs/layout'
-import { userFromId, users } from '/@/libs/useAllUsers'
-import useModal from '/@/libs/useModal'
-import { vars } from '/@/theme'
-import { PlainMessage } from '@bufbuild/protobuf'
-import { style } from '@macaron-css/core'
import { styled } from '@macaron-css/solid'
-import { useParams } from '@solidjs/router'
-import { OcCopy2 } from 'solid-icons/oc'
-import { Component, For, JSX, Show, createEffect, createMemo, createResource, createSignal } from 'solid-js'
-import { createStore } from 'solid-js/store'
-import toast from 'solid-toast'
-
-const ContentContainer = styled('div', {
- base: {
- display: 'grid',
- gridTemplateColumns: '380px 1fr',
- gap: '40px',
- position: 'relative',
- },
-})
-const SidebarContainer = styled('div', {
+import { Outlet, useMatch, useNavigate } from '@solidjs/router'
+import { ErrorBoundary, Show, Suspense, useTransition } from 'solid-js'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import ErrorView from '/@/components/layouts/ErrorView'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import { SideView } from '/@/components/layouts/SideView'
+import SuspenseContainer from '/@/components/layouts/SuspenseContainer'
+import SettingSkeleton from '/@/components/templates/SettingSkeleton'
+import { useApplicationData } from '/@/routes'
+
+const SideMenu = styled('div', {
base: {
position: 'sticky',
- top: '64px',
- padding: '24px 40px',
- backgroundColor: vars.bg.white1,
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- },
-})
-const SidebarOptions = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '12px',
-
- fontSize: '20px',
- color: vars.text.black1,
- },
-})
-const SidebarNavAnchor = styled('a', {
- base: {
- color: vars.text.black2,
- textDecoration: 'none',
- selectors: {
- '&:hover': {
- color: vars.text.black1,
- },
- },
- },
-})
-const ConfigsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '24px',
- },
-})
-const SettingFieldSet = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '16px',
- padding: '24px',
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- background: vars.bg.white1,
- },
-})
-const EnvVarContainerClass = style({
- display: 'grid',
- gridTemplateColumns: '1fr 1fr 1fr',
- gap: '16px',
-})
-const EnvVarsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '8px',
- },
-})
-const EnvVarKeyCode = styled('code', {
- base: {
- padding: '8px 12px',
- borderRadius: '4px',
- fontSize: '14px',
-
width: '100%',
+ top: '0',
display: 'flex',
- alignItems: 'center',
- },
-})
-const EnvVarInputContainer = styled('div', {
- base: {
- display: 'flex',
- alignItems: 'center',
- gap: '4px',
- },
-})
-const EnvVarButtonContainer = styled('div', {
- base: {
- display: 'flex',
- justifyContent: 'space-between',
- gap: '16px',
+ flexDirection: 'column',
},
})
export default () => {
- const params = useParams()
- const [app, { refetch: refetchApp }] = createResource(
- () => params.id,
- (id) => client.getApplication({ id }),
- )
- const [repo] = createResource(
- () => app()?.repositoryId,
- (id) => client.getRepository({ repositoryId: id }),
- )
- const loaded = () => !!(users() && app() && repo())
-
- const update = async (req: PlainMessage) => {
- try {
- await client.updateApplication(req)
- toast.success('アプリ設定を更新しました')
- refetchApp()
- } catch (e) {
- handleAPIError(e, 'アプリ設定の更新に失敗しました')
- }
- }
-
- const GeneralConfigsContainer: Component = () => {
- // 現在の設定で初期化
- const [generalConfig, setGeneralConfig] = createStore({
- name: app().name,
- repositoryId: app().repositoryId,
- refName: app().refName,
- })
- const branchesSuggestion = useBranchesSuggestion(
- () => app()?.repositoryId,
- () => generalConfig.refName,
- )
- let formContainer: HTMLFormElement
-
- const updateGeneralSettings: JSX.EventHandler = async (e) => {
- e.preventDefault()
- if (!formContainer.reportValidity()) {
- return
- }
- return update({
- id: app().id,
- name: generalConfig.name,
- repositoryId: generalConfig.repositoryId,
- refName: generalConfig.refName,
- })
- }
-
- return (
-
- )
- }
-
- const BuildConfigsContainer: Component = () => {
- const [config, setConfig] = createStore(structuredClone(app().config.buildConfig))
-
- const updateBuildSettings: JSX.EventHandler = async (e) => {
- // prevent default form submit (reload page)
- e.preventDefault()
-
- try {
- await client.updateApplication({
- id: app().id,
- config: { buildConfig: config },
- })
- toast.success('ビルド設定を更新しました')
- refetchApp()
- } catch (e) {
- handleAPIError(e, 'ビルド設定の更新に失敗しました')
- }
- }
-
- return (
-
- Build Settings
-
-
-
- )
- }
-
- const WebsitesConfigContainer: Component = () => {
- const [websites, setWebsites] = createStore(structuredClone(app().websites))
-
- // refetch時反映
- createEffect(() => setWebsites(structuredClone(app().websites)))
-
- const updateWebsites = () =>
- update({
- id: app().id,
- websites: { websites },
- })
-
- return (
-
-
- Website Settings
-
-
-
-
-
- )
- }
-
- const PortPublicationConfigContainer: Component = () => {
- const [ports, setPorts] = createStore(structuredClone(app().portPublications))
-
- // refetch時反映
- createEffect(() => setPorts(structuredClone(app().portPublications)))
-
- const updatePortPublications = () =>
- update({
- id: app().id,
- portPublications: { portPublications: ports },
- })
-
- return (
-
-
- Port Forwarding
-
-
-
-
-
- )
- }
-
- const OwnerConfigContainer: Component = () => {
- const { Modal, open } = useModal()
-
- const nonOwnerUsers = createMemo(() => {
- return users().filter((user) => !app().ownerIds.includes(user.id)) ?? []
- })
-
- const handleAddOwner = (user: User) =>
- update({
- id: app().id,
- ownerIds: { ownerIds: app().ownerIds.concat(user.id) },
- })
- const handleDeleteOwner = (owner: User) =>
- update({
- id: app().id,
- ownerIds: { ownerIds: app().ownerIds.filter((id) => id !== owner.id) },
- })
-
- return (
- <>
-
-
- Owners
-
-
-
- userFromId(userId))}>
- {(user) => (
-
- )}
-
-
-
-
- {(user) => (
-
- )}
-
-
- >
- )
- }
-
- const EnvVarConfigContainer: Component = () => {
- const [envVars, { refetch: refetchEnvVar }] = createResource(
- () => app().id,
- (id) => client.getEnvVars({ id }),
- )
-
- const EditEnvVarContainer: Component<{
- envVar: ApplicationEnvVar
- }> = (props) => {
- const [isEditing, setIsEditing] = createSignal(false)
- let formRef: HTMLFormElement
- let valueInputRef: HTMLInputElement
- const { Modal: DeleteEnvVarModal, open: openDeleteEnvVarModal, close: closeDeleteEnvVarModal } = useModal()
-
- const handleCopyEnvVarValue = () => writeToClipboard(props.envVar.value)
-
- const handleUpdateEnvVar: JSX.EventHandler = async (e) => {
- // prevent default form submit (reload page)
- e.preventDefault()
-
- // validate form
- if (!formRef.reportValidity()) {
- return
- }
-
- try {
- await client.setEnvVar({
- applicationId: app().id,
- key: props.envVar.key,
- value: valueInputRef.value,
- })
- toast.success('環境変数を更新しました')
- refetchEnvVar()
- setIsEditing(false)
- } catch (e) {
- handleAPIError(e, '環境変数の更新に失敗しました')
- }
- }
-
- const handleDeleteEnvVar: JSX.EventHandler = async (e) => {
- // prevent default form submit (reload page)
- e.preventDefault()
-
- try {
- await client.deleteEnvVar({
- applicationId: app().id,
- key: props.envVar.key,
- })
- toast.success('環境変数を削除しました')
- refetchEnvVar()
- setIsEditing(false)
- } catch (e) {
- handleAPIError(e, '環境変数の削除に失敗しました')
- }
- }
-
- return (
-
- )
- }
-
- const AddEnvVarContainer: Component = () => {
- let formRef: HTMLFormElement
- let keyInputRef: HTMLInputElement
- let valueInputRef: HTMLInputElement
-
- const handleAddEnvVar: JSX.EventHandler = async (e) => {
- // prevent default form submit (reload page)
- e.preventDefault()
-
- // validate form
- if (!formRef.reportValidity()) {
- return
- }
-
- try {
- await client.setEnvVar({
- applicationId: app().id,
- key: keyInputRef.value,
- value: valueInputRef.value,
- })
- toast.success('環境変数を追加しました')
- keyInputRef.value = ''
- valueInputRef.value = ''
- refetchEnvVar()
- } catch (e) {
- handleAPIError(e, '環境変数の追加に失敗しました')
- }
- }
-
- return (
-
- )
- }
-
- return (
-
-
- Environment Variables
-
-
-
-
-
-
- {(envVar) => {
- return
- }}
-
-
-
-
-
- )
- }
+ const { app } = useApplicationData()
+ const loaded = () => !!app()
+ const matchGeneralPage = useMatch(() => `/apps/${app()?.id}/settings/`)
+ const matchBuildPage = useMatch(() => `/apps/${app()?.id}/settings/build`)
+ const matchURLsPage = useMatch(() => `/apps/${app()?.id}/settings/urls`)
+ const matchPortPage = useMatch(() => `/apps/${app()?.id}/settings/portForwarding`)
+ const matchEnvVarsPage = useMatch(() => `/apps/${app()?.id}/settings/envVars`)
+ const matchOwnersPage = useMatch(() => `/apps/${app()?.id}/settings/owners`)
+
+ const [isPending, start] = useTransition()
+ const navigator = useNavigate()
+ const navigate = (path: string) => start(() => navigator(path))
return (
-
-
-
-
-
-
-
-
- General
- Build
- Website
-
- Port Forwarding
-
- Owner
- Environment Variable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ }>
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/dashboard/src/pages/apps/[id]/settings/build.tsx b/dashboard/src/pages/apps/[id]/settings/build.tsx
new file mode 100644
index 000000000..f98abaa5b
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/build.tsx
@@ -0,0 +1,81 @@
+import { SubmitHandler, createForm, reset } from '@modular-forms/solid'
+import { Show, createEffect } from 'solid-js'
+import toast from 'solid-toast'
+import { Button } from '/@/components/UI/Button'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { BuildConfigForm, BuildConfigs, configToForm, formToConfig } from '/@/components/templates/app/BuildConfigs'
+import { client, handleAPIError } from '/@/libs/api'
+import { useApplicationData } from '/@/routes'
+
+export default () => {
+ const { app, refetchApp, hasPermission } = useApplicationData()
+ const loaded = () => !!app()
+
+ const [buildConfig, BuildConfig] = createForm({
+ initialValues: configToForm(structuredClone(app()?.config)),
+ })
+
+ // reset form when app updated
+ createEffect(() => {
+ reset(buildConfig, {
+ initialValues: configToForm(structuredClone(app()?.config)),
+ })
+ })
+
+ const discardChanges = () => {
+ reset(buildConfig)
+ }
+ const handleSubmit: SubmitHandler = async (values) => {
+ try {
+ await client.updateApplication({
+ id: app()?.id,
+ config: {
+ buildConfig: formToConfig(values),
+ },
+ })
+ toast.success('ビルド設定を更新しました')
+ refetchApp()
+ } catch (e) {
+ handleAPIError(e, 'ビルド設定の更新に失敗しました')
+ }
+ }
+
+ return (
+
+
+ Build
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings/envVars.tsx b/dashboard/src/pages/apps/[id]/settings/envVars.tsx
new file mode 100644
index 000000000..cd7cbc2e4
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/envVars.tsx
@@ -0,0 +1,231 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { styled } from '@macaron-css/solid'
+import { SubmitHandler, createForm, custom, getValue, getValues, insert, remove, reset } from '@modular-forms/solid'
+import { Component, For, Show, createEffect, createReaction, createResource, on } from 'solid-js'
+import toast from 'solid-toast'
+import { ApplicationEnvVars } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { TextField } from '/@/components/UI/TextField'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { client, handleAPIError } from '/@/libs/api'
+import { useApplicationData } from '/@/routes'
+import { colorVars, textVars } from '/@/theme'
+
+const EnvVarsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ rowGap: '8px',
+ columnGap: '24px',
+
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+ },
+})
+
+const EnvVarConfig: Component<{
+ appId: string
+ envVars: PlainMessage
+ refetchEnvVars: () => void
+}> = (props) => {
+ const [envVarForm, EnvVar] = createForm>({
+ initialValues: {
+ variables: props.envVars.variables,
+ },
+ })
+
+ const discardChanges = () => {
+ reset(envVarForm, {
+ initialValues: {
+ variables: props.envVars.variables,
+ },
+ })
+ stripEnvVars()
+ }
+
+ // reset form when envVars updated
+ createEffect(
+ on(
+ () => props.envVars,
+ () => {
+ discardChanges()
+ },
+ ),
+ )
+
+ const stripEnvVars = () => {
+ const forms = getValues(envVarForm, 'variables') as PlainMessage['variables']
+ // remove all empty env vars
+ // 後ろから見ていって、空のものを削除する
+ for (let i = forms.length - 1; i >= 0; i--) {
+ if (forms[i].key === '' && forms[i].value === '') {
+ remove(envVarForm, 'variables', { at: i })
+ }
+ }
+
+ // add empty env var
+ insert(envVarForm, 'variables', {
+ value: { key: '', value: '', system: false },
+ })
+ // 次にvariablesが変更された時に1度だけ再度stripする
+ track(() => getValues(envVarForm, 'variables'))
+ }
+ const track = createReaction(() => {
+ stripEnvVars()
+ })
+
+ const isUniqueKey = (key?: string) => {
+ const sameKey = (getValues(envVarForm, 'variables') as PlainMessage['variables'])
+ .map((envVar) => envVar.key)
+ .filter((k) => k === key)
+ return sameKey.length === 1
+ }
+
+ const handleSubmit: SubmitHandler> = async (values) => {
+ const oldVars = new Map(
+ props.envVars.variables.filter((envVar) => !envVar.system).map((envVar) => [envVar.key, envVar.value]),
+ )
+ const newVars = new Map(
+ values.variables
+ .filter((envVar) => !envVar.system && envVar.key !== '')
+ .map((envVar) => [envVar.key, envVar.value]),
+ )
+
+ const addedKeys = [...newVars.keys()].filter((key) => !oldVars.has(key))
+ const deletedKeys = [...oldVars.keys()].filter((key) => !newVars.has(key))
+ const updatedKeys = [...oldVars.keys()].filter((key) =>
+ newVars.has(key) ? oldVars.get(key) !== newVars.get(key) : false,
+ )
+
+ const addEnvVarRequests = Array.from([...addedKeys, ...updatedKeys]).map((key) => {
+ return client.setEnvVar({
+ applicationId: props.appId,
+ key,
+ value: newVars.get(key),
+ })
+ })
+ const deleteEnvVarRequests = Array.from(deletedKeys).map((key) => {
+ return client.deleteEnvVar({
+ applicationId: props.appId,
+ key,
+ })
+ })
+ try {
+ await Promise.all([...addEnvVarRequests, ...deleteEnvVarRequests])
+ toast.success('環境変数を更新しました')
+ props.refetchEnvVars()
+ } catch (e) {
+ handleAPIError(e, '環境変数の更新に失敗しました')
+ }
+ }
+
+ return (
+
+
+
+
+ Key
+ Value
+
+ {(fieldArray) => (
+
+ {(_, index) => {
+ const isSystem = () => getValue(envVarForm, `variables.${index()}.system`, { shouldActive: false })
+
+ return (
+ <>
+
+ val === '' && index() !== fieldArray.items.length - 1 ? 'Please enter a key' : '',
+ ]}
+ >
+ {(field, fieldProps) => (
+
+ )}
+
+
+ {(field, fieldProps) => (
+
+ )}
+
+ >
+ )
+ }}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default () => {
+ const { app, hasPermission } = useApplicationData()
+ const [envVars, { refetch: refetchEnvVars }] = createResource(
+ () => app()?.id,
+ (id) => client.getEnvVars({ id }),
+ )
+
+ const loaded = () => !!envVars()
+
+ return (
+
+ Environment Variables
+ 環境変数の閲覧・設定はアプリケーションのオーナーのみが行えます
+ }
+ >
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings/general.tsx b/dashboard/src/pages/apps/[id]/settings/general.tsx
new file mode 100644
index 000000000..2d802b71f
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/general.tsx
@@ -0,0 +1,170 @@
+import { styled } from '@macaron-css/solid'
+import { SubmitHandler, createForm, reset } from '@modular-forms/solid'
+import { useNavigate } from '@solidjs/router'
+import { Component, Show, createEffect, on } from 'solid-js'
+import toast from 'solid-toast'
+import { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { FormItem } from '/@/components/templates/FormItem'
+import { AppGeneralConfig, AppGeneralForm } from '/@/components/templates/app/AppGeneralConfig'
+import { client, handleAPIError } from '/@/libs/api'
+import useModal from '/@/libs/useModal'
+import { useApplicationData } from '/@/routes'
+import { colorVars, textVars } from '/@/theme'
+
+const DeleteAppNotice = styled('div', {
+ base: {
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const DeleteApp: Component<{
+ app: Application
+ repo: Repository
+ hasPermission: boolean
+}> = (props) => {
+ const { Modal, open, close } = useModal()
+ const navigate = useNavigate()
+
+ const deleteApplication = async () => {
+ try {
+ await client.deleteApplication({ id: props.app.id })
+ toast.success('アプリケーションを削除しました')
+ close()
+ navigate('/apps')
+ } catch (e) {
+ handleAPIError(e, 'アプリケーションの削除に失敗しました')
+ }
+ }
+
+ return (
+ <>
+
+
+
+ このアプリケーションを削除します。
+
+
+
+
+
+
+
+ Delete Application
+
+
+ deployed_code
+ {props.app.name}
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default () => {
+ const { app, refetchApp, repo, hasPermission } = useApplicationData()
+ const loaded = () => !!(app() && repo())
+
+ const [generalForm, General] = createForm({
+ initialValues: {
+ name: app()?.name,
+ repositoryId: app()?.repositoryId,
+ refName: app()?.refName,
+ },
+ })
+
+ createEffect(
+ on(app, (app) => {
+ reset(generalForm, {
+ initialValues: {
+ name: app?.name,
+ repositoryId: app?.repositoryId,
+ refName: app?.refName,
+ },
+ })
+ }),
+ )
+
+ const handleSubmit: SubmitHandler = async (values) => {
+ try {
+ await client.updateApplication({
+ id: app()?.id,
+ ...values,
+ })
+ toast.success('アプリケーション設定を更新しました')
+ refetchApp()
+ } catch (e) {
+ handleAPIError(e, 'アプリケーション設定の更新に失敗しました')
+ }
+ }
+ const discardChanges = () => {
+ reset(generalForm)
+ }
+
+ return (
+
+ General
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings/owners.tsx b/dashboard/src/pages/apps/[id]/settings/owners.tsx
new file mode 100644
index 000000000..5bd4e00a8
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/owners.tsx
@@ -0,0 +1,59 @@
+import { Show } from 'solid-js'
+import toast from 'solid-toast'
+import { User } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { DataTable } from '/@/components/layouts/DataTable'
+import OwnerList from '/@/components/templates/OwnerList'
+import { client, handleAPIError } from '/@/libs/api'
+import { userFromId, users } from '/@/libs/useAllUsers'
+import { useApplicationData } from '/@/routes'
+
+export default () => {
+ const { app, refetchApp, hasPermission } = useApplicationData()
+ const loaded = () => !!(app() && users())
+
+ const handleAddOwner = async (user: User) => {
+ const newOwnerIds = app()?.ownerIds.concat(user.id)
+ try {
+ await client.updateApplication({
+ id: app()?.id,
+ ownerIds: { ownerIds: newOwnerIds },
+ })
+ toast.success('アプリケーションオーナーを追加しました')
+ refetchApp()
+ } catch (e) {
+ handleAPIError(e, 'アプリケーションオーナーの追加に失敗しました')
+ }
+ }
+
+ const handleDeleteOwner = async (user: User) => {
+ const newOwnerIds = app()?.ownerIds.filter((id) => id !== user.id)
+ try {
+ await client.updateApplication({
+ id: app()?.id,
+ ownerIds: { ownerIds: newOwnerIds },
+ })
+ toast.success('アプリケーションオーナーを削除しました')
+ refetchApp()
+ } catch (e) {
+ handleAPIError(e, 'アプリケーションオーナーの削除に失敗しました')
+ }
+ }
+
+ return (
+
+ Owners
+
+ オーナーはアプリ設定の変更, アプリログ/メトリクスの閲覧, 環境変数の閲覧, ビルドログの閲覧が可能になります
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx b/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx
new file mode 100644
index 000000000..a575bef28
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx
@@ -0,0 +1,104 @@
+import { styled } from '@macaron-css/solid'
+import { Form, SubmitHandler, createFormStore, reset } from '@modular-forms/solid'
+import { For, Show, createEffect } from 'solid-js'
+import toast from 'solid-toast'
+import { Button } from '/@/components/UI/Button'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { PortPublicationSettings, PortSettingsStore } from '/@/components/templates/app/PortPublications'
+import { client, handleAPIError, systemInfo } from '/@/libs/api'
+import { portPublicationProtocolMap } from '/@/libs/application'
+import { useApplicationData } from '/@/routes'
+
+const Li = styled('li', {
+ base: {
+ margin: '0 0 0 16px',
+ },
+})
+
+export default () => {
+ const { app, refetchApp, hasPermission } = useApplicationData()
+ const loaded = () => !!(app() && systemInfo())
+ const form = createFormStore({
+ initialValues: {
+ ports: structuredClone(app()?.portPublications),
+ },
+ })
+
+ // reset form when app updated
+ createEffect(() => {
+ reset(form, {
+ initialValues: {
+ ports: structuredClone(app()?.portPublications),
+ },
+ })
+ })
+
+ const discardChanges = () => {
+ reset(form)
+ }
+ const handleSubmit: SubmitHandler = async (value) => {
+ try {
+ await client.updateApplication({
+ id: app()?.id,
+ portPublications: {
+ portPublications: value.ports,
+ },
+ })
+ toast.success('ポート公開設定を更新しました')
+ refetchApp()
+ } catch (e) {
+ handleAPIError(e, 'ポート公開設定の更新に失敗しました')
+ }
+ }
+
+ return (
+
+
+ Port Forwarding
+
+ TCP/UDPポート公開設定 (複数設定可能)
+ 使用可能なポート:
+
+ {(port) => (
+
+ {port.startPort}/{portPublicationProtocolMap[port.protocol]} ~{port.endPort}/
+ {portPublicationProtocolMap[port.protocol]}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/[id]/settings/urls.tsx b/dashboard/src/pages/apps/[id]/settings/urls.tsx
new file mode 100644
index 000000000..5a0af2836
--- /dev/null
+++ b/dashboard/src/pages/apps/[id]/settings/urls.tsx
@@ -0,0 +1,114 @@
+import { createFormStore, getValue, getValues, setValue } from '@modular-forms/solid'
+import { Show, createResource } from 'solid-js'
+import toast from 'solid-toast'
+import { DeployType } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { DataTable } from '/@/components/layouts/DataTable'
+import { WebsiteSetting, WebsiteSettings, newWebsite } from '/@/components/templates/app/WebsiteSettings'
+import { client, handleAPIError } from '/@/libs/api'
+import { useApplicationData } from '/@/routes'
+
+export default () => {
+ const { app, refetchApp, hasPermission } = useApplicationData()
+
+ const [websiteForms, { mutate }] = createResource(
+ () => app()?.websites,
+ (websites) => {
+ return websites.map((website) => {
+ const form = createFormStore({
+ initialValues: {
+ state: 'noChange',
+ website: structuredClone(website),
+ },
+ })
+ return form
+ })
+ },
+ )
+ const addWebsiteForm = () => {
+ const form = createFormStore({
+ initialValues: {
+ state: 'added',
+ website: newWebsite(),
+ },
+ })
+ mutate((forms) => {
+ return forms?.concat([form])
+ })
+ }
+ const deleteWebsiteForm = (index: number) => {
+ if (!websiteForms.latest) return
+
+ const state = getValue(websiteForms()[index], 'state')
+ if (state === 'added') {
+ mutate((forms) => {
+ return forms?.filter((_, i) => i !== index)
+ })
+ } else {
+ setValue(websiteForms()[index], 'state', 'readyToDelete')
+ handleApplyChanges()
+ }
+ }
+
+ const handleApplyChanges = async () => {
+ try {
+ /**
+ * 送信するWebsite設定
+ * - 変更を保存しないものの、initial value
+ * - 変更して保存するもの ( = `readyToSave`)
+ * - 追加するもの ( = `added`)
+ * - 削除しないもの ( = not `readyToDelete`)
+ */
+ const websitesToSave = websiteForms()
+ ?.map((form) => {
+ const values = getValues(form)
+ switch (values.state) {
+ case 'noChange':
+ return form.internal.initialValues.website
+ case 'readyToChange':
+ return values.website
+ case 'added':
+ return values.website
+ case 'readyToDelete':
+ return undefined
+ }
+ })
+ .filter((w): w is Exclude => w !== undefined)
+
+ await client.updateApplication({
+ id: app()?.id,
+ websites: {
+ websites: websitesToSave,
+ },
+ })
+ toast.success('ウェブサイト設定を保存しました')
+ refetchApp()
+ } catch (e) {
+ // `readyToChange` を `noChange` に戻す
+ for (const form of websiteForms() ?? []) {
+ const values = getValues(form)
+ if (values.state === 'readyToChange') {
+ setValue(form, 'state', 'noChange')
+ }
+ }
+ handleAPIError(e, 'Failed to save website settings')
+ }
+ }
+
+ return (
+
+ URLs
+
+ {(nonNullForms) => (
+
+ )}
+
+
+ )
+}
diff --git a/dashboard/src/pages/apps/new.tsx b/dashboard/src/pages/apps/new.tsx
index 1a8f716b4..1f9486e30 100644
--- a/dashboard/src/pages/apps/new.tsx
+++ b/dashboard/src/pages/apps/new.tsx
@@ -1,246 +1,687 @@
-import {
- ApplicationConfig,
- CreateApplicationRequest,
- CreateWebsiteRequest,
- PortPublication,
- RuntimeConfig,
-} from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { FormCheckBox, FormTextBig } from '/@/components/AppsNew'
-import { BuildConfigMethod, BuildConfigs } from '/@/components/BuildConfigs'
-import { Button } from '/@/components/Button'
-import { Checkbox } from '/@/components/Checkbox'
-import { Header } from '/@/components/Header'
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { InputBar, InputLabel } from '/@/components/Input'
-import { InputSuggestion } from '/@/components/InputSuggestion'
-import { PortPublicationSettings } from '/@/components/PortPublications'
-import { RepositoryInfo } from '/@/components/RepositoryInfo'
-import { WebsiteSettings } from '/@/components/WebsiteSettings'
-import { client, handleAPIError } from '/@/libs/api'
-import { useBranchesSuggestion } from '/@/libs/branchesSuggestion'
-import { Container } from '/@/libs/layout'
-import { vars } from '/@/theme'
-import { PlainMessage } from '@bufbuild/protobuf'
import { styled } from '@macaron-css/solid'
+import { Field, Form, FormStore, createFormStore, getValue, getValues, setValue, validate } from '@modular-forms/solid'
+import { Title } from '@solidjs/meta'
import { A, useNavigate, useSearchParams } from '@solidjs/router'
-import { BsArrowLeftShort } from 'solid-icons/bs'
-import { JSX, Show, createResource } from 'solid-js'
-import { createStore } from 'solid-js/store'
+import Fuse from 'fuse.js'
+import {
+ Accessor,
+ Component,
+ For,
+ Match,
+ Setter,
+ Show,
+ Switch,
+ createEffect,
+ createMemo,
+ createResource,
+ createSignal,
+ onMount,
+} from 'solid-js'
import toast from 'solid-toast'
+import {
+ Application,
+ ApplicationConfig,
+ GetApplicationsRequest_Scope,
+ GetRepositoriesRequest_Scope,
+ Repository,
+} from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { Progress } from '/@/components/UI/StepProgress'
+import { TextField } from '/@/components/UI/TextField'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import { WithNav } from '/@/components/layouts/WithNav'
+import { CheckBox } from '/@/components/templates/CheckBox'
+import { FormItem } from '/@/components/templates/FormItem'
+import { List } from '/@/components/templates/List'
+import { Nav } from '/@/components/templates/Nav'
+import { AppGeneralConfig, AppGeneralForm } from '/@/components/templates/app/AppGeneralConfig'
+import { BuildConfigForm, BuildConfigs, configToForm, formToConfig } from '/@/components/templates/app/BuildConfigs'
+import { WebsiteSetting, newWebsite } from '/@/components/templates/app/WebsiteSettings'
+import ReposFilter from '/@/components/templates/repo/ReposFilter'
+import { client, handleAPIError, systemInfo } from '/@/libs/api'
+import { Provider, providerToIcon, repositoryURLToProvider } from '/@/libs/application'
+import { colorOverlay } from '/@/libs/colorOverlay'
+import { colorVars, textVars } from '/@/theme'
-const AppTitle = styled('div', {
+const RepositoryStepContainer = styled('div', {
base: {
- marginTop: '48px',
- height: '46px',
- lineHeight: '46px',
+ width: '100%',
+ height: '100%',
+ overflowY: 'hidden',
+ padding: '24px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ },
+})
+const RepositoryListContainer = styled('div', {
+ base: {
+ width: '100%',
+ height: '100%',
+ overflowY: 'auto',
display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: 'column',
},
})
+const RepositoryButton = styled('button', {
+ base: {
+ width: '100%',
+ background: colorVars.semantic.ui.primary,
+ border: 'none',
+ cursor: 'pointer',
-const AppsTitle = styled('div', {
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ '&:not(:last-child)': {
+ borderBottom: `1px solid ${colorVars.semantic.ui.border}`,
+ },
+ },
+ },
+})
+const RepositoryRow = styled('div', {
base: {
- fontSize: '32px',
- fontWeight: 700,
- color: vars.text.black1,
+ width: '100%',
+ padding: '16px',
+ display: 'grid',
+ gridTemplateColumns: '24px auto 1fr auto',
+ gridTemplateRows: 'auto auto',
+ gridTemplateAreas: `
+ "icon name count button"
+ ". url url button"`,
+ rowGap: '2px',
+ columnGap: '8px',
+ textAlign: 'left',
+ },
+})
+const RepositoryIcon = styled('div', {
+ base: {
+ gridArea: 'icon',
display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
},
})
-
-const Arrow = styled('div', {
+const RepositoryName = styled('div', {
+ base: {
+ width: '100%',
+ gridArea: 'name',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.black,
+ ...textVars.h4.bold,
+ },
+})
+const AppCount = styled('div', {
base: {
- fontSize: '32px',
- color: vars.text.black1,
display: 'flex',
+ alignItems: 'center',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
+ },
+})
+const RepositoryUrl = styled('div', {
+ base: {
+ gridArea: 'url',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ color: colorVars.semantic.text.grey,
+ ...textVars.caption.regular,
},
})
+const CreateAppText = styled('div', {
+ base: {
+ gridArea: 'button',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ gap: '4px',
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+ },
+})
+const RegisterRepositoryButton = styled('div', {
+ base: {
+ width: '100%',
+ height: 'auto',
+ padding: '20px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ cursor: 'pointer',
+ background: colorVars.semantic.ui.primary,
+ color: colorVars.semantic.text.black,
+ ...textVars.text.bold,
+
+ selectors: {
+ '&:hover': {
+ background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover),
+ },
+ },
+ },
+})
+
+const RepositoryStep: Component<{
+ setRepo: (repo: Repository) => void
+}> = (props) => {
+ const [repos] = createResource(() =>
+ client.getRepositories({
+ scope: GetRepositoriesRequest_Scope.MINE,
+ }),
+ )
+ const [apps] = createResource(() => client.getApplications({ scope: GetApplicationsRequest_Scope.ALL }))
+
+ const [query, setQuery] = createSignal('')
+ const [provider, setProvider] = createSignal(['GitHub', 'GitLab', 'Gitea'])
+
+ const filteredReposByProvider = createMemo(() => {
+ const p = provider()
+ return repos()?.repositories.filter((r) => p.includes(repositoryURLToProvider(r.url)))
+ })
+ const repoWithApps = createMemo(() => {
+ const appsMap = apps()?.applications.reduce((acc, app) => {
+ if (!acc[app.repositoryId]) acc[app.repositoryId] = 0
+ acc[app.repositoryId]++
+ return acc
+ }, {} as { [id: Repository['id']]: number })
+
+ return (
+ filteredReposByProvider()?.map(
+ (
+ repo,
+ ): {
+ repo: Repository
+ appCount: number
+ } => ({ repo, appCount: appsMap?.[repo.id] ?? 0 }),
+ ) ?? []
+ )
+ })
-const ContentContainer = styled('div', {
+ const fuse = createMemo(
+ () =>
+ new Fuse(repoWithApps(), {
+ keys: ['repo.name', 'repo.htmlUrl'],
+ }),
+ )
+ const filteredRepos = createMemo(() => {
+ if (query() === '') return repoWithApps()
+ return fuse()
+ .search(query())
+ .map((r) => r.item)
+ })
+
+ return (
+
+ setQuery(e.currentTarget.value)}
+ leftIcon={search}
+ rightIcon={}
+ />
+
+
+
+
+ Repository Not Found
+
+
+ }
+ >
+ {(repo) => (
+ {
+ props.setRepo(repo.repo)
+ }}
+ type="button"
+ >
+
+ {providerToIcon(repositoryURLToProvider(repo.repo.url), 24)}
+ {repo.repo.name}
+ {repo.appCount > 0 && `${repo.appCount} apps`}
+ {repo.repo.htmlUrl}
+
+ Create App
+ arrow_forward
+
+
+
+ )}
+
+
+
+
+ add
+ Register Repository
+
+
+
+
+ )
+}
+
+const FormsContainer = styled('div', {
base: {
- marginTop: '24px',
- display: 'grid',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
gap: '40px',
},
})
-
-const MainContentContainer = styled('div', {
+const FormContainer = styled('div', {
base: {
+ width: '100%',
+ padding: '24px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
+
+ background: colorVars.semantic.ui.primary,
+ borderRadius: '8px',
+ },
+})
+const FormTitle = styled('h2', {
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ overflowWrap: 'anywhere',
+ color: colorVars.semantic.text.black,
+ ...textVars.h2.medium,
+ },
+})
+const ButtonsContainer = styled('div', {
+ base: {
+ display: 'flex',
+ gap: '20px',
},
})
-const FormContainer = styled('form', {
+type GeneralForm = AppGeneralForm & BuildConfigForm & { startOnCreate: boolean }
+
+const GeneralStep: Component<{
+ repo: Repository
+ createAppForm: FormStore
+ backToRepoStep: () => void
+ proceedToWebsiteStep: () => void
+}> = (props) => {
+ return (
+
+ )
+}
+
+const DomainsContainer = styled('div', {
base: {
+ width: '100%',
display: 'flex',
flexDirection: 'column',
- gap: '20px',
+ alignItems: 'center',
+ gap: '24px',
+ },
+})
+const AddMoreButtonContainer = styled('div', {
+ base: {
+ display: 'flex',
+ justifyContent: 'center',
+ },
+})
+
+const WebsiteStep: Component<{
+ isRuntimeApp: boolean
+ websiteForms: Accessor[]>
+ setWebsiteForms: Setter[]>
+ backToGeneralStep: () => void
+ submit: () => Promise
+}> = (props) => {
+ const addWebsiteForm = () => {
+ const form = createFormStore({
+ initialValues: {
+ state: 'added',
+ website: newWebsite(),
+ },
+ })
+ props.setWebsiteForms((prev) => prev.concat([form]))
+ }
- background: vars.bg.white3,
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- padding: '8px 12px',
+ const handleSubmit = async () => {
+ const isValid = (await Promise.all(props.websiteForms().map((form) => validate(form)))).every((v) => v)
+ if (!isValid) return
+ await props.submit()
+ }
+
+ return (
+
+
+
+
+ link_off
+ URLが設定されていません
+
+
+ }
+ >
+ {(form, i) => (
+ props.setWebsiteForms((prev) => [...prev.slice(0, i()), ...prev.slice(i() + 1)])}
+ hasPermission
+ />
+ )}
+
+ 0}>
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const StepsContainer = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '40px',
+ },
+ variants: {
+ fit: {
+ true: {
+ maxHeight: '100%',
+ },
+ },
},
})
+const formStep = {
+ repository: 0,
+ general: 1,
+ website: 2,
+} as const
+type FormStep = typeof formStep[keyof typeof formStep]
+
export default () => {
- const navigate = useNavigate()
- const [searchParams] = useSearchParams()
+ const [searchParams, setParam] = useSearchParams()
+ const [currentStep, setCurrentStep] = createSignal(formStep.repository)
- const [repo] = createResource(
+ const [repo, { mutate: mutateRepo }] = createResource(
() => searchParams.repositoryID,
(id) => client.getRepository({ repositoryId: id }),
)
- const [buildConfig, setBuildConfig] = createStore['buildConfig']>({
- case: 'runtimeBuildpack',
- value: {
- context: '',
- runtimeConfig: structuredClone(new RuntimeConfig()),
+ // このページに遷移した時にURLパラメータにrepositoryIDがあれば
+ // generalStepに遷移する
+ onMount(() => {
+ if (searchParams.repositoryID !== undefined) {
+ setCurrentStep(formStep.general)
+ }
+ })
+
+ const createAppForm = createFormStore({
+ initialValues: {
+ name: '',
+ refName: '',
+ repositoryId: repo()?.id,
+ startOnCreate: false,
+ ...configToForm(new ApplicationConfig()),
},
})
- const [websites, setWebsites] = createStore[]>([])
- const [ports, setPorts] = createStore[]>([])
- const [request, setRequest] = createStore>({
- name: '',
- refName: '',
- repositoryId: searchParams.repositoryID,
- config: { buildConfig },
- websites: websites,
- portPublications: ports,
- startOnCreate: false,
+ const isRuntimeApp = () => {
+ return (
+ getValue(createAppForm, 'case') === 'runtimeBuildpack' ||
+ getValue(createAppForm, 'case') === 'runtimeCmd' ||
+ getValue(createAppForm, 'case') === 'runtimeDockerfile'
+ )
+ }
+ // repo更新時にcreateAppFormのrepositoryIdを更新する
+ createEffect(() => {
+ setValue(createAppForm, 'repositoryId', repo()?.id)
})
- const branchesSuggestion = useBranchesSuggestion(
- () => searchParams.repositoryID,
- () => request.refName,
- )
+ const [websiteForms, setWebsiteForms] = createSignal[]>([])
+
+ // TODO: ブラウザバック時のrepositoryIDの設定
- const isRuntime = () =>
- (['runtimeBuildpack', 'runtimeCmd', 'runtimeDockerfile'] as BuildConfigMethod[]).includes(buildConfig.case)
+ // repositoryが指定されたらビルド設定に進む
+ createEffect(() => {
+ if (repo() !== undefined) {
+ setParam({ repositoryID: repo()?.id })
+ GoToGeneralStep()
+ }
+ })
- let formContainer: HTMLFormElement
+ const backToRepoStep = () => {
+ setCurrentStep(formStep.repository)
+ // 選択していたリポジトリをリセットする
+ setParam({ repositoryID: undefined })
+ mutateRepo(undefined)
+ }
+ const GoToGeneralStep = () => {
+ setCurrentStep(formStep.general)
+ }
+ const GoToWebsiteStep = () => {
+ setCurrentStep(formStep.website)
+ }
- const createApplication: JSX.EventHandler = async (e) => {
- // prevent default form submit (reload page)
- e.preventDefault()
+ const createApp = async (): Promise => {
+ const values = getValues(createAppForm, { shouldActive: false })
+ const websitesToSave = websiteForms()
+ .map((form) => getValues(form).website)
+ .filter((w): w is Exclude => w !== undefined)
- // validate form
- if (!formContainer.reportValidity()) {
- return
- }
+ const createdApp = await client.createApplication({
+ name: values.name,
+ refName: values.refName,
+ repositoryId: values.repositoryId,
+ config: {
+ buildConfig: formToConfig({
+ case: values.case,
+ config: values.config as BuildConfigs,
+ }),
+ },
+ websites: websitesToSave,
+ startOnCreate: values.startOnCreate,
+ })
+ return createdApp
+ }
+ const navigate = useNavigate()
+ const submit = async () => {
try {
- const res = await client.createApplication(request)
+ const createdApp = await createApp()
toast.success('アプリケーションを登録しました')
- // Application詳細ページに遷移
- navigate(`/apps/${res.id}`)
+ navigate(`/apps/${createdApp.id}`)
} catch (e) {
- return handleAPIError(e, 'アプリケーションの登録に失敗しました')
+ handleAPIError(e, 'アプリケーションの登録に失敗しました')
}
}
- const CreateApplicationSettingsInputForm = (): JSX.Element => {
- return (
-
-
-
-
-
-
-
-
- Application Name
- setRequest('name', e.target.value)}
- required
- />
-
-
-
-
- Branch Name
-
-
-
setRequest('refName', b)}>
- {(onFocus) => (
- setRequest('refName', e.target.value)}
- onFocus={onFocus}
- required
+ return (
+
+ Create Application - NeoShowcase
+
+
+
+
+
+
+
+
+ {(step) => (
+ step.step ? 'complete' : 'incomplete'
+ }
/>
)}
-
-
-
-
- Build Setting
-
-
-
-
-
- Website Setting
-
-
-
-
-
-
-
-
- Port Forwarding
-
-
-
-
-
-
-
-
- Start Immediately
-
+
+
+
+ mutateRepo(repo)} />
+
+
+
+ {(nonNullRepo) => (
+
+ )}
+
+
+
+
-
-
- setRequest('startOnCreate', selected)}
- >
- 今すぐ起動する
-
-
-
-
-
-
-
-
- )
- }
-
- return (
-
-
-
-
-
-
-
-
- Create Application
-
-
-
+
+
+
+
+
+
)
}
diff --git a/dashboard/src/pages/builds.tsx b/dashboard/src/pages/builds.tsx
index a62bcaecb..07d428182 100644
--- a/dashboard/src/pages/builds.tsx
+++ b/dashboard/src/pages/builds.tsx
@@ -1,29 +1,54 @@
-import { BuildList } from '/@/components/BuildList'
-import { Header } from '/@/components/Header'
-import { client } from '/@/libs/api'
-import { Container, PageTitle } from '/@/libs/layout'
-import { styled } from '@macaron-css/solid'
-import { createResource } from 'solid-js'
-import { Show } from 'solid-js'
+import { Title } from '@solidjs/meta'
+import { Component, Show, createMemo, createResource } from 'solid-js'
+import { GetApplicationsRequest_Scope } from '../api/neoshowcase/protobuf/gateway_pb'
+import { MaterialSymbols } from '../components/UI/MaterialSymbols'
+import { MainViewContainer } from '../components/layouts/MainView'
+import { WithNav } from '../components/layouts/WithNav'
+import { BuildList, List } from '../components/templates/List'
+import { Nav } from '../components/templates/Nav'
+import { client } from '../libs/api'
-const PageContainer = styled('div', {
- base: {
- paddingTop: '24px',
- },
-})
+const builds: Component = () => {
+ const [apps] = createResource(() => client.getApplications({ scope: GetApplicationsRequest_Scope.ALL }))
+ const appNameMap = createMemo(() => new Map(apps()?.applications.map((app) => [app.id, app.name])))
-export default () => {
- const [builds] = createResource(() => client.getAllBuilds({ page: 0, limit: 100 }))
+ const [builds] = createResource(() =>
+ client.getAllBuilds({
+ limit: 100,
+ }),
+ )
+
+ const sortedBuilds = createMemo(() =>
+ builds.latest !== undefined
+ ? [...builds().builds]
+ .sort((b1, b2) => {
+ return (b2.queuedAt?.toDate().getTime() ?? 0) - (b1.queuedAt?.toDate().getTime() ?? 0)
+ })
+ .map((b) => ({ build: b, appName: appNameMap().get(b.applicationId) }))
+ : [],
+ )
+ const showPlaceHolder = () => builds()?.builds.length === 0
return (
-
-
- Build Queue
-
-
-
-
-
-
+
+ Build Queue - NeoShowcase
+
+
+
+
+
+ }>
+
+
+ deployed_code
+ No Builds
+
+
+
+
+
+
)
}
+
+export default builds
diff --git a/dashboard/src/pages/repos/[id].tsx b/dashboard/src/pages/repos/[id].tsx
index 4794b7d56..8a2ee86e1 100644
--- a/dashboard/src/pages/repos/[id].tsx
+++ b/dashboard/src/pages/repos/[id].tsx
@@ -1,135 +1,50 @@
-import { GetApplicationsRequest_Scope } from '/@/api/neoshowcase/protobuf/gateway_pb'
-import AppRow from '/@/components/AppRow'
-import { Button } from '/@/components/Button'
-import { Card, CardItem, CardItemContent, CardItemTitle, CardItems, CardTitle, CardsRow } from '/@/components/Card'
-import { Header } from '/@/components/Header'
-import { ModalButtonsContainer, ModalContainer, ModalText } from '/@/components/Modal'
-import RepositoryNav from '/@/components/RepositoryNav'
-import { URLText } from '/@/components/URLText'
-import { client, handleAPIError } from '/@/libs/api'
-import { Container } from '/@/libs/layout'
-import useModal from '/@/libs/useModal'
-import { useNavigate, useParams } from '@solidjs/router'
-import { For, JSX, Show, createResource } from 'solid-js'
-import toast from 'solid-toast'
+import { Title } from '@solidjs/meta'
+import { Outlet, useMatch, useNavigate } from '@solidjs/router'
+import { ErrorBoundary, Show, Suspense, startTransition } from 'solid-js'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { TabRound } from '/@/components/UI/TabRound'
+import ErrorView from '/@/components/layouts/ErrorView'
+import { WithNav } from '/@/components/layouts/WithNav'
+import { RepositoryNav } from '/@/components/templates/repo/RepositoryNav'
+import { useRepositoryData } from '/@/routes'
export default () => {
- const navigate = useNavigate()
- const params = useParams()
- const [repo] = createResource(
- () => params.id,
- (id) => client.getRepository({ repositoryId: id }),
- )
- const [apps] = createResource(
- () => params.id,
- (id) =>
- client
- .getApplications({ scope: GetApplicationsRequest_Scope.REPOSITORY, repositoryId: id })
- .then((r) => r.applications),
- )
-
- const { Modal: DeleteRepoModal, open: openDeleteRepoModal, close: closeDeleteRepoModal } = useModal()
-
- // リポジトリに紐づくアプリケーションが存在するかどうか
- const canDeleteRepository = (): boolean => apps()?.length === 0
+ const { repo } = useRepositoryData()
- // リポジトリの削除処理
- const handleDeleteRepository: JSX.EventHandler = async () => {
- try {
- await client.deleteRepository({ repositoryId: repo()?.id })
- toast.success('リポジトリを削除しました')
- closeDeleteRepoModal()
- // アプリ一覧ページに遷移
- navigate('/apps')
- } catch (e) {
- handleAPIError(e, 'リポジトリの削除に失敗しました')
- }
- }
+ const matchIndexPage = useMatch(() => `/repos/${repo()?.id}/`)
+ const matchSettingsPage = useMatch(() => `/repos/${repo()?.id}/settings/*`)
- const handleCreateApplication = async () => {
- navigate(`/apps/new?repositoryID=${repo()?.id}`)
- }
+ const navigator = useNavigate()
+ const navigate = (path: string) => startTransition(() => navigator(path))
return (
-
-
-
-
-
-
- Actions
-
-
-
-
-
-
- 本当に削除しますか?
-
-
-
-
-
-
-
-
- Info
-
-
- ID
- {repo().id}
-
-
- Name
- {repo().name}
-
-
- URL
-
-
-
-
-
-
- 0}>
-
+
+ {`${repo()!.name} - Repository - NeoShowcase`}
+
+
+
+ navigate(`/repos/${repo()!.id}`)} state={matchIndexPage() ? 'active' : 'default'}>
+ insert_chart
+ Info
+
+ navigate(`/repos/${repo()!.id}/settings`)}
+ state={matchSettingsPage() ? 'active' : 'default'}
>
- Apps
-
-
-
-
-
-
+ settings
+ Settings
+
+
+
+
+ }>
+
+
+
+
+
+
+
)
}
diff --git a/dashboard/src/pages/repos/[id]/index.tsx b/dashboard/src/pages/repos/[id]/index.tsx
new file mode 100644
index 000000000..152d046ee
--- /dev/null
+++ b/dashboard/src/pages/repos/[id]/index.tsx
@@ -0,0 +1,103 @@
+import { styled } from '@macaron-css/solid'
+import { useNavigate } from '@solidjs/router'
+import { Show, createMemo, useTransition } from 'solid-js'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import { URLText } from '/@/components/UI/URLText'
+import { DataTable } from '/@/components/layouts/DataTable'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import SuspenseContainer from '/@/components/layouts/SuspenseContainer'
+import { AppsList, List } from '/@/components/templates/List'
+import { useRepositoryData } from '/@/routes'
+
+const MainView = styled('div', {
+ base: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '32px',
+ },
+})
+
+export default () => {
+ const { repo, apps, hasPermission } = useRepositoryData()
+ const loaded = () => !!(repo() && apps())
+ const navigator = useNavigate()
+ const showPlaceHolder = createMemo(() => apps()?.length === 0)
+
+ const AddNewAppButton = () => (
+
+ )
+
+ const [isPending] = useTransition()
+
+ return (
+
+
+
+
+
+
+ Apps
+
+
+
+
+ }>
+
+
+ deployed_code
+ No Apps
+
+
+
+
+
+
+ Information
+
+
+
+ Name
+ {repo()!.name}
+
+
+
+
+ URL
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/repos/[id]/settings.tsx b/dashboard/src/pages/repos/[id]/settings.tsx
index 398840d9c..980caae74 100644
--- a/dashboard/src/pages/repos/[id]/settings.tsx
+++ b/dashboard/src/pages/repos/[id]/settings.tsx
@@ -1,300 +1,91 @@
-import {
- CreateRepositoryAuth,
- Repository_AuthMethod,
- UpdateRepositoryRequest,
- User,
-} from '/@/api/neoshowcase/protobuf/gateway_pb'
-import { FormTextBig } from '/@/components/AppsNew'
-import { Button } from '/@/components/Button'
-import { Header } from '/@/components/Header'
-import { InfoTooltip } from '/@/components/InfoTooltip'
-import { InputBar, InputLabel } from '/@/components/Input'
-import { RepositoryAuthSettings } from '/@/components/RepositoryAuthSettings'
-import RepositoryNav from '/@/components/RepositoryNav'
-import { UserSearch } from '/@/components/UserSearch'
-import { client, handleAPIError } from '/@/libs/api'
-import { extractRepositoryNameFromURL } from '/@/libs/application'
-import { Container } from '/@/libs/layout'
-import { userFromId, users } from '/@/libs/useAllUsers'
-import useModal from '/@/libs/useModal'
-import { vars } from '/@/theme'
-import { PartialMessage, PlainMessage } from '@bufbuild/protobuf'
import { styled } from '@macaron-css/solid'
-import { useParams } from '@solidjs/router'
-import { Component, JSX, Show, createEffect, createMemo, createResource, createSignal } from 'solid-js'
-import { createStore } from 'solid-js/store'
-import toast from 'solid-toast'
-
-const ContentContainer = styled('div', {
- base: {
- marginTop: '24px',
- display: 'grid',
- gridTemplateColumns: '380px 1fr',
- gap: '40px',
- position: 'relative',
- },
-})
-const SidebarContainer = styled('div', {
+import { Outlet, useMatch, useNavigate } from '@solidjs/router'
+import { ErrorBoundary, Show, Suspense, useTransition } from 'solid-js'
+import { Button } from '/@/components/UI/Button'
+import { MaterialSymbols } from '/@/components/UI/MaterialSymbols'
+import ErrorView from '/@/components/layouts/ErrorView'
+import { MainViewContainer } from '/@/components/layouts/MainView'
+import { SideView } from '/@/components/layouts/SideView'
+import SuspenseContainer from '/@/components/layouts/SuspenseContainer'
+import SettingSkeleton from '/@/components/templates/SettingSkeleton'
+import { useRepositoryData } from '/@/routes'
+
+const SideMenu = styled('div', {
base: {
position: 'sticky',
- top: '64px',
- padding: '24px 40px',
- backgroundColor: vars.bg.white1,
- borderRadius: '4px',
- border: `1px solid ${vars.bg.white4}`,
- },
-})
-const SidebarOptions = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '12px',
-
- fontSize: '20px',
- color: vars.text.black1,
- },
-})
-const SidebarNavAnchor = styled('a', {
- base: {
- color: vars.text.black2,
- textDecoration: 'none',
- selectors: {
- '&:hover': {
- color: vars.text.black1,
- },
- },
- },
-})
-const ConfigsContainer = styled('div', {
- base: {
- display: 'flex',
- flexDirection: 'column',
- gap: '24px',
- },
-})
-const SettingFieldSet = styled('div', {
- base: {
+ width: '100%',
+ top: '0',
display: 'flex',
flexDirection: 'column',
- gap: '16px',
- padding: '24px',
- border: `1px solid ${vars.bg.white4}`,
- borderRadius: '4px',
- background: vars.bg.white1,
},
})
export default () => {
- const params = useParams()
- const [repo, { refetch: refetchRepo }] = createResource(
- () => params.id,
- (repositoryId) => client.getRepository({ repositoryId }),
- )
- const loaded = () => !!(users() && repo())
-
- const update = async (req: PlainMessage) => {
- try {
- await client.updateRepository(req)
- toast.success('リポジトリ設定を更新しました')
- refetchRepo()
- } catch (e) {
- handleAPIError(e, 'リポジトリ設定の更新に失敗しました')
- }
- }
-
- const GeneralConfigsContainer: Component = () => {
- let formContainer: HTMLFormElement
-
- // 現在の設定で初期化
- const [generalConfig, setGeneralConfig] = createStore>({
- id: repo().id,
- name: repo().name,
- url: repo().url,
- })
- const [updateAuthConfig, setUpdateAuthConfig] = createSignal(false)
- const mapAuthMethod = (authMethod: Repository_AuthMethod): PlainMessage['auth'] => {
- switch (authMethod) {
- case Repository_AuthMethod.NONE:
- return { case: 'none', value: {} }
- case Repository_AuthMethod.BASIC:
- return { case: 'basic', value: { username: '', password: '' } }
- case Repository_AuthMethod.SSH:
- return { case: 'ssh', value: { keyId: '' } }
- }
- }
- const [authConfig, setAuthConfig] = createStore>({
- auth: mapAuthMethod(repo().authMethod),
- })
-
- // URLからリポジトリ名を自動入力
- createEffect(() => {
- const repositoryName = extractRepositoryNameFromURL(generalConfig.url)
- setGeneralConfig('name', repositoryName)
- })
+ const { repo } = useRepositoryData()
+ const loaded = () => !!repo()
+ const matchGeneralPage = useMatch(() => `/repos/${repo()?.id}/settings/`)
+ const matchAuthPage = useMatch(() => `/repos/${repo()?.id}/settings/authorization`)
+ const matchOwnersPage = useMatch(() => `/repos/${repo()?.id}/settings/owners`)
- const onClickSave: JSX.EventHandler = async (e) => {
- e.preventDefault()
- if (!formContainer.reportValidity()) {
- return
- }
- return update({ ...generalConfig, auth: authConfig })
- }
+ const [isPending, start] = useTransition()
+ const navigator = useNavigate()
+ const navigate = (path: string) => start(() => navigator(path))
- return (
-
- )
- }
-
- const OwnerConfigContainer: Component = () => {
- const { Modal, open } = useModal()
-
- const nonOwnerUsers = createMemo(() => {
- return users().filter((user) => !repo().ownerIds.includes(user.id)) ?? []
- })
-
- const handleAddOwner = async (user: User) => {
- const updateApplicationRequest: PartialMessage = {
- id: repo().id,
- ownerIds: {
- ownerIds: repo().ownerIds.concat(user.id),
- },
- }
-
- try {
- await client.updateRepository(updateApplicationRequest)
- toast.success('リポジトリオーナーを追加しました')
- refetchRepo()
- } catch (e) {
- handleAPIError(e, 'リポジトリオーナーの追加に失敗しました')
- }
- }
- const handleDeleteOwner = async (owner: User) => {
- const updateApplicationRequest: PartialMessage = {
- id: repo().id,
- ownerIds: {
- ownerIds: repo().ownerIds.filter((id) => id !== owner.id),
- },
- }
-
- try {
- await client.updateRepository(updateApplicationRequest)
- toast.success('リポジトリのオーナーを削除しました')
- refetchRepo()
- } catch (e) {
- handleAPIError(e, 'リポジトリのオーナーの削除に失敗しました')
- }
- }
-
- return (
- <>
-
-
- Owners
-
-
-
- userFromId(userId))}>
- {(user) => (
- )}
-
-
-
-
- {(user) => (
- )}
-
-
- >
- )
- }
-
- return (
-
-
-
-
-
-
-
-
- General
- Owner
-
-
-
-
-
-
-
-
+
+
+
+ }>
+ }>
+
+
+
+
+
+
+
-
+
)
}
diff --git a/dashboard/src/pages/repos/[id]/settings/authorization.tsx b/dashboard/src/pages/repos/[id]/settings/authorization.tsx
new file mode 100644
index 000000000..5fdcf4639
--- /dev/null
+++ b/dashboard/src/pages/repos/[id]/settings/authorization.tsx
@@ -0,0 +1,117 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { SubmitHandler, createForm, reset } from '@modular-forms/solid'
+import { Component, Show } from 'solid-js'
+import toast from 'solid-toast'
+import { CreateRepositoryAuth, Repository, Repository_AuthMethod } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { AuthForm, RepositoryAuthSettings, formToAuth } from '/@/components/templates/repo/RepositoryAuthSettings'
+import { client, handleAPIError } from '/@/libs/api'
+import { useRepositoryData } from '/@/routes'
+
+const mapAuthMethod = (authMethod: Repository_AuthMethod): PlainMessage['auth']['case'] => {
+ switch (authMethod) {
+ case Repository_AuthMethod.NONE:
+ return 'none'
+ case Repository_AuthMethod.BASIC:
+ return 'basic'
+ case Repository_AuthMethod.SSH:
+ return 'ssh'
+ }
+}
+
+const AuthConfig: Component<{
+ repo: Repository
+ refetchRepo: () => void
+ hasPermission: boolean
+}> = (props) => {
+ const [authForm, Auth] = createForm({
+ initialValues: {
+ url: props.repo.url,
+ case: mapAuthMethod(props.repo.authMethod),
+ auth: {
+ basic: {
+ username: '',
+ password: '',
+ },
+ ssh: {
+ keyId: '',
+ },
+ },
+ },
+ })
+
+ const handleSubmit: SubmitHandler = async (values) => {
+ try {
+ await client.updateRepository({
+ id: props.repo.id,
+ url: values.url,
+ auth: {
+ auth: formToAuth(values),
+ },
+ })
+ toast.success('リポジトリの設定を更新しました')
+ props.refetchRepo()
+ } catch (e) {
+ handleAPIError(e, 'リポジトリの設定の更新に失敗しました')
+ }
+ }
+
+ const discardChanges = () => {
+ reset(authForm)
+ }
+
+ const AuthSetting = RepositoryAuthSettings({
+ formStore: authForm,
+ hasPermission: props.hasPermission,
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default () => {
+ const { repo, refetchRepo, hasPermission } = useRepositoryData()
+
+ return (
+
+ Authorization
+
+
+
+
+ )
+}
diff --git a/dashboard/src/pages/repos/[id]/settings/general.tsx b/dashboard/src/pages/repos/[id]/settings/general.tsx
new file mode 100644
index 000000000..4cff4a9e5
--- /dev/null
+++ b/dashboard/src/pages/repos/[id]/settings/general.tsx
@@ -0,0 +1,192 @@
+import { PlainMessage } from '@bufbuild/protobuf'
+import { styled } from '@macaron-css/solid'
+import { SubmitHandler, createForm, required, reset } from '@modular-forms/solid'
+import { useNavigate } from '@solidjs/router'
+import { Component, Show, createEffect } from 'solid-js'
+import toast from 'solid-toast'
+import { Application, Repository, UpdateRepositoryRequest } from '/@/api/neoshowcase/protobuf/gateway_pb'
+import { Button } from '/@/components/UI/Button'
+import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm'
+import { TextField } from '/@/components/UI/TextField'
+import { DataTable } from '/@/components/layouts/DataTable'
+import FormBox from '/@/components/layouts/FormBox'
+import { FormItem } from '/@/components/templates/FormItem'
+import { client, handleAPIError } from '/@/libs/api'
+import { providerToIcon, repositoryURLToProvider } from '/@/libs/application'
+import useModal from '/@/libs/useModal'
+import { useRepositoryData } from '/@/routes'
+import { colorVars, textVars } from '/@/theme'
+
+type GeneralForm = Required, 'name'>>
+
+const NameConfig: Component<{
+ repo: Repository
+ refetchRepo: () => void
+ hasPermission: boolean
+}> = (props) => {
+ const [generalForm, General] = createForm({
+ initialValues: {
+ name: props.repo.name,
+ },
+ })
+
+ createEffect(() => {
+ reset(generalForm, 'name', {
+ initialValue: props.repo.name,
+ })
+ })
+
+ const handleSubmit: SubmitHandler = async (values) => {
+ try {
+ await client.updateRepository({
+ id: props.repo.id,
+ name: values.name,
+ })
+ toast.success('リポジトリ名を更新しました')
+ props.refetchRepo()
+ } catch (e) {
+ handleAPIError(e, 'リポジトリ名の更新に失敗しました')
+ }
+ }
+ const discardChanges = () => {
+ reset(generalForm)
+ }
+
+ return (
+
+
+
+
+ {(field, fieldProps) => (
+